feat: accept nested local capability maps

This commit is contained in:
lda
2026-07-22 08:46:15 +07:00 Verified
parent 58e4364012
commit cde0a3c14f
11 changed files with 229 additions and 30 deletions
+38
View File
@@ -1423,6 +1423,44 @@ async def test_add_step_from_capability_wires_route_inputs_and_state_outputs(
}
@pytest.mark.asyncio
async def test_add_step_from_capability_projects_nested_local_input(
tmp_path: Path,
) -> None:
api, service, authoring = _draft_api(
FileWorkflowArtifactStore(tmp_path / "nested_capability_input"),
register_echo=True,
)
service.register_specs("demo.personal", _nested_report)
draft = _nested_report_draft()
draft["steps"] = {}
draft["routes"] = {}
await api.create_draft_workspace(workspace_id="nested_add", draft=draft)
result = await authoring.add_step_from_capability(
workspace_id="nested_add",
revision=1,
step_id="render",
capability_name="demo.personal.nested_report",
routes={"ok": "__end__"},
input_map={"input.title": "report.title"},
bind_outputs={},
)
workspace = await api.get_draft_workspace(
workspace_id="nested_add", include_draft=True
)
validated = await api.validate_draft_workspace(workspace_id="nested_add")
assert result["revision"] == 2
assert workspace["draft"]["input_schema"]["properties"]["title"]["type"] == (
"string"
)
assert workspace["draft"]["steps"]["render"]["input"] == [
{"target": "report.title", "path": "input.title"}
]
assert validated["status"] == "valid", validated["diagnostics"]
@pytest.mark.asyncio
async def test_add_step_from_capability_rejects_existing_step_id(
tmp_path: Path,
+62 -4
View File
@@ -515,6 +515,7 @@ def test_wf_draft_map_help_explains_replace_merge_and_validate() -> None:
assert "draft validate" in input_help
assert "input.text=text" in input_help
assert "input.text=local.text" in input_help
assert "input.title=report.title" in input_help
assert "replaces the full output map" in output_help
assert "Use --merge only" in output_help
assert "draft validate" in output_help
@@ -535,6 +536,7 @@ def test_wf_draft_bind_help_explains_direction() -> None:
assert "validate" in help_text
assert "project missing schema" in help_text
assert "set-input --merge" in help_text
assert "local.report.title" in help_text
def test_wf_draft_add_capability_help_explains_explicit_wiring() -> None:
@@ -550,7 +552,7 @@ def test_wf_draft_add_capability_help_explains_explicit_wiring() -> None:
assert "draft validate" in output
assert "wf draft add capability report_ws" in output
assert "Repeat the flag" in output
assert "--input state.title=title --input state.summary=summary" in output
assert "--input state.title=report.title" in output
assert (
"--bind-output title=state.title --bind-output summary=state.summary" in output
)
@@ -587,7 +589,7 @@ def test_wf_draft_add_capability_calls_composed_local_handler(monkeypatch) -> No
"--route",
"ok=__end__",
"--input",
"input.text=text",
"input.text=report.text",
"--bind-output",
"value=state.value",
],
@@ -602,7 +604,7 @@ def test_wf_draft_add_capability_calls_composed_local_handler(monkeypatch) -> No
assert call["route_from_step"] == "start"
assert call["route_from_outcome"] == "ok"
assert call["routes"] == {"ok": "__end__"}
assert call["input_map"] == {"input.text": "text"}
assert call["input_map"] == {"input.text": "report.text"}
assert call["bind_outputs"] == {"value": "state.value"}
@@ -1487,6 +1489,62 @@ def test_wf_draft_set_input_rejects_local_prefixed_target() -> None:
assert result.exit_code == 2
output = " ".join(result.output.split())
assert "bare local field" in output
assert "rootless node-local path" in output
assert "input.text=text" in output
assert "input.text=local.text" in output
def test_wf_draft_set_input_accepts_nested_rootless_target(monkeypatch) -> None:
calls: list[dict[str, Any]] = []
class FakeHandlers:
async def set_step_input_map(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"revision": 2, "status": "valid"}
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
result = runner.invoke(
app,
[
"draft",
"set-input",
"report_ws",
"--revision",
"1",
"--step",
"render",
"--map",
"input.title=report.title",
],
)
assert result.exit_code == 0, result.output
assert calls[0]["input_map"] == {"input.title": "report.title"}
def test_wf_draft_set_input_rejects_malformed_local_path(monkeypatch) -> None:
monkeypatch.setattr(
"wf_cli.commands.drafts.load_cli_context",
lambda _ctx: (_ for _ in ()).throw(AssertionError("context loaded")),
)
result = runner.invoke(
app,
[
"draft",
"set-input",
"report_ws",
"--revision",
"1",
"--step",
"render",
"--map",
"input.title=report..title",
],
)
assert result.exit_code == 2
assert "rootless node-local path" in result.output
assert "context loaded" not in result.output
+52
View File
@@ -1396,6 +1396,58 @@ def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
]
def test_wf_draft_set_input_preserves_nested_target_over_rpc(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"nested_input_ws",
"--capability",
"wf.std.constant",
"--name",
"nested_input",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(
app,
[
*base_args,
"draft",
"set-input",
"nested_input_ws",
"--revision",
"1",
"--step",
"call",
"--map",
"input.value=payload.value",
],
)
inspected = runner.invoke(
app,
[*base_args, "draft", "inspect", "nested_input_ws", "--include-draft"],
)
assert result.exit_code == 0, result.output
assert inspected.exit_code == 0, inspected.output
draft = json.loads(inspected.output)["draft"]
assert draft["steps"]["call"]["input"] == [
{"target": "payload.value", "path": "input.value"}
]
def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server)
+2 -2
View File
@@ -802,7 +802,7 @@ async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
"workspace_id": "focused_ws",
"revision": 3,
"step_id": "call",
"input_map": {"input.value": "value"},
"input_map": {"input.value": "payload.value"},
},
)
output_mapped = await _rpc(
@@ -866,7 +866,7 @@ async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
assert draft["routes"]["call"]["ok"] == "__end__"
assert draft["steps"]["call"]["input"] == [
{
"target": "value",
"target": "payload.value",
"path": "input.value",
},
{
@@ -760,6 +760,34 @@ async def test_rpc_client_draft_workspace_add_step_from_capability(tmp_path) ->
assert result["status"] == "valid"
async def test_rpc_client_preserves_nested_local_path_strings() -> None:
calls: list[dict[str, Any]] = []
class Client(RpcDraftClientMixin):
async def _call(self, method: str, params: dict[str, object]):
calls.append({"method": method, "params": params})
return {"revision": 2}
client = Client()
await client.bind_draft(
workspace_id="ws",
revision=1,
step_id="render",
source_path="input.title",
target_path="local.report.title",
)
await client.add_step_from_capability(
workspace_id="ws",
revision=2,
step_id="render",
capability_name="demo.report",
input_map={"input.title": "report.title"},
)
assert calls[0]["params"]["target_path"] == "local.report.title"
assert calls[1]["params"]["input_map"] == {"input.title": "report.title"}
@pytest.mark.parametrize(
("step_id", "step", "expected_wire"),
[