Files
lda-wf/tests/wf_api/test_drafts_service.py
T

2065 lines
68 KiB
Python

from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from pydantic import BaseModel
from tests.wf_mcp.test_support import echo_tool
from wf_api.draft_authoring import DraftOutcomeRef, WorkflowDraftAuthoringApi
from wf_api.drafts import WorkflowDraftApi
from wf_api.service import WorkflowApi
from wf_artifacts import FileDraftWorkspaceStore, FileWorkflowArtifactStore
from wf_authoring import node
from wf_mcp.broker import WfMcpService
from wf_mcp.broker.service.workflow_operation_context import context_from_service
from wf_mcp.models import ConnectionConfig
from wf_mcp.storage import FileStore
from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
def _echo_draft() -> dict[str, Any]:
return {
"name": "echo",
"input_schema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
"state_schema": {"fields": {"echoed": {"type": "string"}}},
"output_schema": {
"type": "object",
"properties": {"echoed": {"type": "string"}},
"required": ["echoed"],
},
"start": "echo",
"steps": {
"echo": {
"use": "demo.personal.echo_tool",
"input": [
{
"target": {"root": "local", "parts": ["text"]},
"path": {"root": "input", "parts": ["text"]},
}
],
"output": [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
}
],
}
},
"routes": {"echo": {"ok": "__end__"}},
}
class _Snapshot(BaseModel):
clicked: bool
class _SnapshotOutput(BaseModel):
after: _Snapshot
class _SnapshotInput(BaseModel):
pass
@node(name="snapshot_tool", outcomes=("ok", "skipped"))
def _snapshot_tool(payload: _SnapshotInput) -> _SnapshotOutput:
return _SnapshotOutput(after=_Snapshot(clicked=True))
def _draft_api(
artifact_store: FileWorkflowArtifactStore,
*,
register_echo: bool = False,
) -> tuple[WorkflowDraftApi, WfMcpService, WorkflowDraftAuthoringApi]:
mcp_root = artifact_store.root / "drafts_mcp" / str(id(artifact_store))
service = WfMcpService(
store=FileStore(mcp_root),
artifact_store=artifact_store,
draft_workspace_store=FileDraftWorkspaceStore(mcp_root),
)
if register_echo:
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_specs("demo.personal", echo_tool)
context = context_from_service(service)
return (
WorkflowDraftApi(context),
service,
WorkflowDraftAuthoringApi(context, WorkflowDraftApi(context)),
)
@pytest.mark.asyncio
async def test_patch_draft_applies_json_patch(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
result = await api.patch_draft(
draft=_echo_draft(),
patch=[
{
"op": "replace",
"path": "/steps/echo/input/0/target/parts/0",
"value": "message",
}
],
)
assert result["status"] == "invalid"
assert result["draft"]["steps"]["echo"]["input"][0]["target"] == {
"root": "local",
"parts": ["message"],
}
@pytest.mark.asyncio
async def test_create_draft_workspace_creates_workspace(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_workspace")
api, _service, _authoring = _draft_api(artifact_store)
result = await api.create_draft_workspace(
workspace_id="echo_ws",
title="Echo Workspace",
draft=_echo_draft(),
)
assert result["workspace_id"] == "echo_ws"
assert result["revision"] == 1
fetched = await api.get_draft_workspace(workspace_id="echo_ws", include_draft=True)
assert fetched["workspace_id"] == "echo_ws"
assert fetched["title"] == "Echo Workspace"
assert fetched["draft"]["steps"]["echo"]["use"] == "demo.personal.echo_tool"
@pytest.mark.asyncio
async def test_list_draft_workspaces_returns_sorted_summaries_without_drafts(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_list_workspaces")
api, _service, _authoring = _draft_api(artifact_store)
await api.create_draft_workspace(
workspace_id="b_draft",
title="B Draft",
draft=_echo_draft(),
)
await api.create_draft_workspace(
workspace_id="a_draft",
title="A Draft",
draft=_echo_draft(),
)
result = await api.list_draft_workspaces()
assert [workspace["workspace_id"] for workspace in result["workspaces"]] == [
"a_draft",
"b_draft",
]
assert result["workspaces"][0]["title"] == "A Draft"
assert "draft" not in result["workspaces"][0]
@pytest.mark.asyncio
async def test_delete_draft_workspace_is_idempotent(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_delete_workspace")
api, _service, _authoring = _draft_api(artifact_store)
await api.create_draft_workspace(
workspace_id="echo_ws",
draft=_echo_draft(),
)
deleted = await api.delete_draft_workspace(workspace_id="echo_ws")
deleted_again = await api.delete_draft_workspace(workspace_id="echo_ws")
listed = await api.list_draft_workspaces()
assert deleted["workspace_id"] == "echo_ws"
assert deleted["deleted"] is True
assert deleted["status"] == "deleted"
assert deleted_again["workspace_id"] == "echo_ws"
assert deleted_again["deleted"] is False
assert deleted_again["status"] == "not_found"
assert listed["workspaces"] == []
@pytest.mark.asyncio
async def test_patch_draft_workspace_updates_revision(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_workspace")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="echo_ws",
draft=_echo_draft(),
)
patched = await api.patch_draft_workspace(
workspace_id="echo_ws",
revision=1,
patch=[{"op": "replace", "path": "/name", "value": "echo_v2"}],
)
assert patched["revision"] == 2
assert patched["status"] == "valid"
@pytest.mark.asyncio
async def test_draft_workspace_patch_helpers_update_revision_and_bindings(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_helpers")
api, _service, _authoring = _draft_api(artifact_store)
await api.create_draft_workspace(
workspace_id="echo_ws",
draft=_echo_draft(),
)
named = await api.set_draft_name(
workspace_id="echo_ws",
revision=1,
name="echo_v2",
)
routed = await api.set_draft_route(
workspace_id="echo_ws",
revision=2,
step_id="echo",
outcome="error",
target="__end__",
)
input_mapped = await api.set_step_input_map(
workspace_id="echo_ws",
revision=3,
step_id="echo",
input_map={"input.text": "message"},
)
output_mapped = await api.set_step_output_map(
workspace_id="echo_ws",
revision=4,
step_id="echo",
output_map={"echoed": "state.echoed"},
)
fetched = await api.get_draft_workspace(workspace_id="echo_ws", include_draft=True)
assert named["revision"] == 2
assert routed["revision"] == 3
assert input_mapped["revision"] == 4
assert output_mapped["revision"] == 5
assert fetched["draft"]["name"] == "echo_v2"
assert fetched["draft"]["routes"]["echo"]["error"] == "__end__"
assert fetched["draft"]["steps"]["echo"]["input"] == [
{
"target": "message",
"path": "input.text",
}
]
assert fetched["draft"]["steps"]["echo"]["output"] == [
{
"source": "echoed",
"target": "state.echoed",
}
]
@pytest.mark.asyncio
async def test_step_map_helpers_merge_with_existing_bindings(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_helper_merge")
api, _service, _authoring = _draft_api(artifact_store)
await api.create_draft_workspace(
workspace_id="echo_ws",
draft=_echo_draft(),
)
input_mapped = await api.set_step_input_map(
workspace_id="echo_ws",
revision=1,
step_id="echo",
input_map={"input.extra": "extra"},
merge=True,
)
output_mapped = await api.set_step_output_map(
workspace_id="echo_ws",
revision=2,
step_id="echo",
output_map={"extra": "state.extra"},
merge=True,
)
replaced = await api.set_step_input_map(
workspace_id="echo_ws",
revision=3,
step_id="echo",
input_map={"input.final": "final"},
)
fetched = await api.get_draft_workspace(workspace_id="echo_ws", include_draft=True)
assert input_mapped["revision"] == 2
assert output_mapped["revision"] == 3
assert replaced["revision"] == 4
assert fetched["draft"]["steps"]["echo"]["input"] == [
{
"target": "final",
"path": "input.final",
}
]
assert fetched["draft"]["steps"]["echo"]["output"] == [
{
"source": "echoed",
"target": "state.echoed",
},
{
"source": "extra",
"target": "state.extra",
},
]
@pytest.mark.asyncio
async def test_validate_draft_workspace_refreshes_status(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_validate_workspace")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
draft = _echo_draft()
draft["routes"]["echo"] = {"typo": "__end__"}
await api.create_draft_workspace(
workspace_id="echo_ws",
draft=draft,
)
payload = await api.validate_draft_workspace(workspace_id="echo_ws")
fetched = await api.get_draft_workspace(workspace_id="echo_ws")
assert payload["revision"] == 1
assert payload["status"] == "invalid"
assert payload["diagnostics"][0]["code"] in (
"unknown_outcome",
"undeclared_edge_outcome",
)
assert fetched["status"] == "invalid"
@pytest.mark.asyncio
async def test_validate_draft_workspace_suggests_bind(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_repair_hint")
api, service, authoring = _draft_api(artifact_store)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_specs("demo.personal", _snapshot_tool)
await api.create_draft_workspace(
workspace_id="snapshot_ws",
draft={
"name": "snapshot",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "snap",
"steps": {
"snap": {
"use": "demo.personal.snapshot_tool",
"input": [],
"output": [
{
"source": {"root": "local", "parts": ["after"]},
"target": {"root": "state", "parts": ["after"]},
}
],
}
},
"routes": {"snap": {"ok": "__end__"}},
},
)
payload = await api.validate_draft_workspace(workspace_id="snapshot_ws")
diagnostic = payload["diagnostics"][0]
assert diagnostic["code"] == "invalid_destination_path"
assert diagnostic["step_id"] == "snap"
assert diagnostic["repair_hint"] == (
"wf draft bind snapshot_ws --revision 1 "
"--step snap --from local.after --to state.after"
)
@pytest.mark.asyncio
async def test_patch_draft_workspace_validates_new_use_step_with_context_specs(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_new_use")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
await api.create_draft_workspace(
workspace_id="echo_ws",
draft=_echo_draft(),
)
patched = await api.patch_draft_workspace(
workspace_id="echo_ws",
revision=1,
patch=[
{
"op": "add",
"path": "/steps/snap",
"value": {
"use": "demo.personal.snapshot_tool",
"input": [],
"output": [
{
"source": {"root": "local", "parts": ["after"]},
"target": {"root": "state", "parts": ["after"]},
}
],
},
},
{
"op": "replace",
"path": "/routes/echo/ok",
"value": "snap",
},
{
"op": "add",
"path": "/routes/snap",
"value": {"ok": "__end__"},
},
],
)
diagnostic = patched["diagnostics"][0]
assert patched["status"] == "invalid"
assert diagnostic["code"] == "invalid_destination_path"
assert diagnostic["step_id"] == "snap"
assert diagnostic["details"] == {
"output_field": "after",
"state_path": "state.after",
}
@pytest.mark.asyncio
async def test_create_minimal_draft_workspace_minimal_success_path(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_minimal_workspace")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
result = await authoring.create_minimal_draft_workspace(
workspace_id="echo_minimal",
name="echo",
capability_name="demo.personal.echo_tool",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
state_schema={"fields": {"echoed": {"type": "string"}}},
output_schema={
"type": "object",
"properties": {"echoed": {"type": "string"}},
"required": ["echoed"],
},
input_map={"input.text": "text"},
output_map={"echoed": "state.echoed"},
)
assert result["workspace_id"] == "echo_minimal"
fetched = await api.get_draft_workspace(
workspace_id="echo_minimal", include_draft=True
)
assert fetched["draft"]["routes"]["call"]["ok"] == "__end__"
assert fetched["draft"]["steps"]["call"]["use"] == "demo.personal.echo_tool"
@pytest.mark.asyncio
async def test_delegation_smoke_validate_draft_equivalence(tmp_path: Path) -> None:
"""WorkflowSurfaceHandlers.validate_draft delegates to WorkflowDraftApi."""
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_delegation_smoke")
mcp_root = artifact_store.root / "delegation_mcp"
service = WfMcpService(
store=FileStore(mcp_root),
artifact_store=artifact_store,
draft_workspace_store=FileDraftWorkspaceStore(mcp_root),
)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_specs("demo.personal", echo_tool)
h = WorkflowSurfaceHandlers(service)
context = context_from_service(service)
api = WorkflowDraftApi(context)
draft = _echo_draft()
handler_result = await h.validate_draft(draft=draft)
api_result = await api.validate_draft(draft=draft)
assert handler_result["status"] == api_result["status"]
assert handler_result["diagnostics"] == api_result["diagnostics"]
assert (
handler_result["compiled_plan"]["nodes"] == api_result["compiled_plan"]["nodes"]
)
@pytest.mark.asyncio
async def test_facade_delegates_semantic_authoring_to_authoring_service(
tmp_path: Path,
) -> None:
"""WorkflowApi constructs a sibling WorkflowDraftAuthoringApi."""
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_facade_delegation")
mcp_root = artifact_store.root / "facade_mcp"
service = WfMcpService(
store=FileStore(mcp_root),
artifact_store=artifact_store,
draft_workspace_store=FileDraftWorkspaceStore(mcp_root),
)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
context = context_from_service(service)
facade = WorkflowApi(context)
assert facade.draft_authoring is not None
assert isinstance(facade.draft_authoring, WorkflowDraftAuthoringApi)
await facade.create_draft_workspace(
workspace_id="ws1",
draft=_echo_draft(),
)
result = await facade.bind_draft(
workspace_id="ws1",
revision=1,
step_id="echo",
source_path="local.echoed",
target_path="state.echoed",
)
assert result["revision"] == 2
@pytest.mark.asyncio
async def test_bind_draft_workflow_input_to_step_input_projects_input_schema(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_input")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
draft = {**_echo_draft(), "input_schema": {"type": "object", "properties": {}}}
await api.create_draft_workspace(
workspace_id="bind_ws",
draft=draft,
)
result = await authoring.bind_draft(
workspace_id="bind_ws",
revision=1,
step_id="echo",
source_path="input.text",
target_path="local.text",
)
workspace = await api.get_draft_workspace(
workspace_id="bind_ws", include_draft=True
)
assert result["revision"] == 2
assert workspace["draft"]["input_schema"]["properties"]["text"]["type"] == "string"
assert workspace["draft"]["steps"]["echo"]["input"] == [
{"target": "text", "path": "input.text"}
]
@pytest.mark.asyncio
async def test_bind_draft_workflow_input_to_step_input_reuses_existing_schema(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(
tmp_path / "drafts_bind_existing_input_schema"
)
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="bind_ws",
draft=_echo_draft(),
)
result = await authoring.bind_draft(
workspace_id="bind_ws",
revision=1,
step_id="echo",
source_path="input.text",
target_path="local.text",
)
workspace = await api.get_draft_workspace(
workspace_id="bind_ws", include_draft=True
)
assert result["status"] == "valid", result["diagnostics"]
assert result["revision"] == 2
assert workspace["draft"]["steps"]["echo"]["input"] == [
{"target": "text", "path": "input.text"}
]
@pytest.mark.asyncio
async def test_bind_draft_workflow_input_to_step_input_can_repeat(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_repeat")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(workspace_id="bind_ws", draft=_echo_draft())
first = await authoring.bind_draft(
workspace_id="bind_ws",
revision=1,
step_id="echo",
source_path="input.text",
target_path="local.text",
)
second = await authoring.bind_draft(
workspace_id="bind_ws",
revision=first["revision"],
step_id="echo",
source_path="input.text",
target_path="local.text",
)
workspace = await api.get_draft_workspace(
workspace_id="bind_ws", include_draft=True
)
assert first["status"] == "valid"
assert second["status"] == "valid"
assert second["revision"] == 3
assert workspace["draft"]["steps"]["echo"]["input"] == [
{"target": "text", "path": "input.text"}
]
@pytest.mark.asyncio
async def test_bind_draft_workflow_state_to_step_input_reuses_existing_schema(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(
tmp_path / "drafts_bind_existing_state_schema"
)
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
draft = {
**_echo_draft(),
"state_schema": {
"type": "object",
"properties": {"text": {"type": "string"}},
},
"steps": {
"echo": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [],
}
},
}
await api.create_draft_workspace(workspace_id="bind_ws", draft=draft)
result = await authoring.bind_draft(
workspace_id="bind_ws",
revision=1,
step_id="echo",
source_path="state.text",
target_path="local.text",
)
workspace = await api.get_draft_workspace(
workspace_id="bind_ws", include_draft=True
)
assert result["status"] == "valid", result["diagnostics"]
assert result["revision"] == 2
assert workspace["draft"]["steps"]["echo"]["input"] == [
{"target": "text", "path": "state.text"}
]
@pytest.mark.asyncio
async def test_bind_draft_output_to_nested_state_projects_state_schema(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_output_nested")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", _snapshot_tool)
await api.create_draft_workspace(
workspace_id="snapshot_ws",
draft={
"name": "snapshot",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "snap",
"steps": {
"snap": {
"use": "demo.personal.snapshot_tool",
"input": [],
"output": [],
}
},
"routes": {"snap": {"ok": "__end__"}},
},
)
result = await authoring.bind_draft(
workspace_id="snapshot_ws",
revision=1,
step_id="snap",
source_path="local.after",
target_path="state.session.after",
)
workspace = await api.get_draft_workspace(
workspace_id="snapshot_ws", include_draft=True
)
assert result["revision"] == 2
assert (
workspace["draft"]["state_schema"]["properties"]["session"]["properties"][
"after"
]["$ref"]
== "#/$defs/_Snapshot"
)
assert workspace["draft"]["steps"]["snap"]["output"] == [
{"source": "after", "target": "state.session.after"}
]
@pytest.mark.asyncio
async def test_bind_draft_rejects_unsupported_direction(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_bad_direction")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(workspace_id="bind_ws", draft=_echo_draft())
with pytest.raises(ValueError, match="unsupported bind direction"):
await authoring.bind_draft(
workspace_id="bind_ws",
revision=1,
step_id="echo",
source_path="input.message",
target_path="state.message",
)
@pytest.mark.asyncio
async def test_add_step_from_capability_wires_route_inputs_and_state_outputs(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_add_step")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
await api.create_draft_workspace(
workspace_id="echo_ws",
draft=_echo_draft(),
)
result = await authoring.add_step_from_capability(
workspace_id="echo_ws",
revision=1,
step_id="snap",
capability_name="demo.personal.snapshot_tool",
route_from_step="echo",
route_from_outcome="ok",
routes={"ok": "__end__", "skipped": "__end__"},
input_map={},
bind_outputs={"after": "state.after"},
)
assert result["revision"] == 2
assert result["status"] == "valid"
fetched = await api.get_draft_workspace(workspace_id="echo_ws", include_draft=True)
draft = fetched["draft"]
assert draft["steps"]["snap"]["use"] == "demo.personal.snapshot_tool"
assert draft["routes"]["echo"]["ok"] == "snap"
assert draft["routes"]["snap"]["ok"] == "__end__"
assert draft["steps"]["snap"]["output"] == [
{
"source": "after",
"target": "state.after",
}
]
assert draft["state_schema"]["properties"]["after"]["$ref"] == "#/$defs/_Snapshot"
assert draft["state_schema"]["$defs"]["_Snapshot"]["properties"]["clicked"] == {
"title": "Clicked",
"type": "boolean",
}
@pytest.mark.asyncio
async def test_add_step_from_capability_rejects_existing_step_id(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_add_step_duplicate")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="echo_ws",
draft=_echo_draft(),
)
with pytest.raises(ValueError, match="draft step 'echo' already exists"):
await authoring.add_step_from_capability(
workspace_id="echo_ws",
revision=1,
step_id="echo",
capability_name="demo.personal.echo_tool",
route_from_step=None,
route_from_outcome="ok",
routes={"ok": "__end__"},
input_map={},
bind_outputs={},
)
@pytest.mark.asyncio
async def test_branch_draft_updates_routes_atomically(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_branch")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="branching",
draft={
"name": "branching",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "classify",
"steps": {
"classify": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [],
},
"tool_error": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [],
},
},
"routes": {
"classify": {"ok": "classify"},
"tool_error": {"ok": "__end__"},
},
},
)
result = await authoring.branch_draft(
workspace_id="branching",
revision=1,
step_id="classify",
routes={"ok": "classify", "error": "tool_error"},
)
assert result["revision"] == 2
workspace = await api.get_draft_workspace(
workspace_id="branching", include_draft=True
)
assert workspace["draft"]["routes"]["classify"] == {
"ok": "classify",
"error": "tool_error",
}
assert "tool_error" in workspace["draft"]["steps"]
@pytest.mark.asyncio
async def test_handle_draft_updates_multiple_source_outcomes(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="handling",
draft={
"name": "handling",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "lookup",
"steps": {
"lookup": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [],
},
"transform": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [],
},
},
"routes": {
"lookup": {"ok": "transform", "error": "lookup"},
"transform": {"ok": "__end__", "error": "transform"},
},
},
)
result = await authoring.handle_draft(
workspace_id="handling",
revision=1,
branches=[
DraftOutcomeRef(step_id="lookup", outcome="error"),
DraftOutcomeRef(step_id="transform", outcome="error"),
],
target="__end__",
)
assert result["revision"] == 2
workspace = await api.get_draft_workspace(
workspace_id="handling", include_draft=True
)
assert workspace["draft"]["routes"]["lookup"]["error"] == "__end__"
assert workspace["draft"]["routes"]["transform"]["error"] == "__end__"
assert workspace["draft"]["routes"]["lookup"]["ok"] == "transform"
assert workspace["draft"]["routes"]["transform"]["ok"] == "__end__"
@pytest.mark.asyncio
async def test_branch_draft_no_change_when_routes_unchanged(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_branch_noop")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="noop_ws",
draft=_echo_draft(),
)
before = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True)
result = await authoring.branch_draft(
workspace_id="noop_ws",
revision=1,
step_id="echo",
routes={"ok": "__end__"},
)
after = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True)
assert result["revision"] == 1
assert after == before
@pytest.mark.asyncio
async def test_branch_draft_no_change_still_checks_revision(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_branch_noop_stale")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="noop_ws",
draft=_echo_draft(),
)
result = await authoring.branch_draft(
workspace_id="noop_ws",
revision=2,
step_id="echo",
routes={"ok": "__end__"},
)
assert result["status"] == "conflict"
assert result["diagnostics"][0]["code"] == "revision_conflict"
@pytest.mark.asyncio
async def test_handle_draft_empty_branches_noop(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle_noop")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="noop_ws",
draft=_echo_draft(),
)
before = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True)
result = await authoring.handle_draft(
workspace_id="noop_ws",
revision=1,
branches=[],
target="fail",
)
after = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True)
assert result["revision"] == 1
assert after == before
@pytest.mark.asyncio
async def test_handle_draft_no_change_still_checks_revision(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle_noop_stale")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="noop_ws",
draft=_echo_draft(),
)
result = await authoring.handle_draft(
workspace_id="noop_ws",
revision=2,
branches=[],
target="fail",
)
assert result["status"] == "conflict"
assert result["diagnostics"][0]["code"] == "revision_conflict"
@pytest.mark.asyncio
async def test_add_step_from_capability_infers_single_outcome_route(
tmp_path: Path,
) -> None:
"""One declared outcome named 'done', no routes supplied -> routes to __end__."""
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_single_outcome")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", echo_tool)
await api.create_draft_workspace(
workspace_id="single",
draft=_echo_draft(),
)
result = await authoring.add_step_from_capability(
workspace_id="single",
revision=1,
step_id="done_step",
capability_name="demo.personal.echo_tool",
)
assert result["revision"] == 2
workspace = await api.get_draft_workspace(workspace_id="single", include_draft=True)
assert workspace["draft"]["routes"]["done_step"] == {"ok": "__end__"}
@pytest.mark.asyncio
async def test_add_step_from_capability_requires_complete_routes_for_multi_outcome(
tmp_path: Path,
) -> None:
"""Multiple declared outcomes with incomplete explicit routes raises ValueError."""
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_multi_outcome")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
await api.create_draft_workspace(
workspace_id="multi",
draft=_echo_draft(),
)
with pytest.raises(ValueError, match="missing routes"):
await authoring.add_step_from_capability(
workspace_id="multi",
revision=1,
step_id="snap",
capability_name="demo.personal.snapshot_tool",
routes={"ok": "__end__"},
)
@pytest.mark.asyncio
async def test_add_step_from_capability_rejects_unknown_routes_for_multi_outcome(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_unknown_outcome")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", _snapshot_tool)
await api.create_draft_workspace(
workspace_id="unknown_multi",
draft=_echo_draft(),
)
with pytest.raises(ValueError, match="unknown routes") as exc_info:
await authoring.add_step_from_capability(
workspace_id="unknown_multi",
revision=1,
step_id="snap",
capability_name="demo.personal.snapshot_tool",
routes={"ok": "__end__", "skipped": "__end__", "typo": "__end__"},
)
message = str(exc_info.value)
assert "declares outcomes ('ok', 'skipped')" in message
assert "unknown routes ['typo']" in message
assert "remove --route entries for ['typo']" in message
@pytest.mark.asyncio
async def test_add_step_rejects_unknown_single_outcome_route_with_repair(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(
tmp_path / "drafts_unknown_single_outcome"
)
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="unknown_single",
draft=_echo_draft(),
)
with pytest.raises(ValueError) as exc_info:
await authoring.add_step_from_capability(
workspace_id="unknown_single",
revision=1,
step_id="second",
capability_name="demo.personal.echo_tool",
routes={"ok": "__end__", "error": "fail"},
)
message = str(exc_info.value)
assert "declares outcomes ('ok',)" in message
assert "unknown routes ['error']" in message
assert "remove --route entries for ['error']" in message
@pytest.mark.asyncio
async def test_add_step_rejects_missing_outcomes_only_with_repair(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_missing_outcome_only")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", _snapshot_tool)
await api.create_draft_workspace(
workspace_id="missing_single",
draft=_echo_draft(),
)
with pytest.raises(ValueError) as exc_info:
await authoring.add_step_from_capability(
workspace_id="missing_single",
revision=1,
step_id="snap",
capability_name="demo.personal.snapshot_tool",
routes={"ok": "__end__"},
)
message = str(exc_info.value)
assert "declares outcomes ('ok', 'skipped')" in message
assert "missing routes ['skipped']" in message
assert "add --route OUTCOME=TARGET for ['skipped']" in message
# -- Draft remove helpers --
@pytest.mark.asyncio
async def test_remove_draft_route_persists_invalid_workspace(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_route")
api, _service, authoring = _draft_api(artifact_store)
await api.create_draft_workspace(
workspace_id="route_ws",
draft={
"name": "route_ws",
"start": "echo",
"steps": {
"echo": {"use": "demo.personal.echo_tool", "input": [], "output": []}
},
"routes": {"echo": {"ok": "__end__"}},
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
},
)
result = await authoring.remove_draft_route(
workspace_id="route_ws",
revision=1,
step_id="echo",
outcome="ok",
)
assert result["revision"] == 2
assert result["status"] == "invalid"
fetched = await api.get_draft_workspace(
workspace_id="route_ws",
include_draft=True,
)
assert fetched["draft"]["routes"]["echo"] == {}
@pytest.mark.asyncio
async def test_remove_draft_step_removes_outgoing_routes_not_inbound_routes(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_step")
api, _service, authoring = _draft_api(artifact_store)
await api.create_draft_workspace(
workspace_id="step_ws",
draft={
"name": "step_ws",
"start": "first",
"steps": {
"first": {"use": "demo.personal.echo_tool", "input": [], "output": []},
"second": {"use": "demo.personal.echo_tool", "input": [], "output": []},
},
"routes": {
"first": {"ok": "second"},
"second": {"ok": "__end__"},
},
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
},
)
result = await authoring.remove_draft_step(
workspace_id="step_ws",
revision=1,
step_id="second",
)
assert result["revision"] == 2
assert result["status"] == "invalid"
assert any(
item["code"] == "unknown_edge_destination" for item in result["diagnostics"]
)
fetched = await api.get_draft_workspace(
workspace_id="step_ws",
include_draft=True,
)
assert "second" not in fetched["draft"]["steps"]
assert "second" not in fetched["draft"]["routes"]
assert fetched["draft"]["routes"]["first"]["ok"] == "second"
@pytest.mark.asyncio
async def test_remove_draft_binding_removes_input_and_output_bindings(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_binding")
api, _service, authoring = _draft_api(artifact_store)
await api.create_draft_workspace(
workspace_id="binding_ws",
draft={
"name": "binding_ws",
"start": "echo",
"steps": {
"echo": {
"use": "demo.personal.echo_tool",
"input": [
{"path": "input.message", "target": "message"},
{"path": "input.extra", "target": "extra"},
],
"output": [
{"source": "echoed", "target": "state.echoed"},
{"source": "debug", "target": "state.debug"},
],
}
},
"routes": {"echo": {"ok": "__end__"}},
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
},
)
result = await authoring.remove_draft_binding(
workspace_id="binding_ws",
revision=1,
step_id="echo",
inputs=("message",),
outputs=("debug",),
)
assert result["revision"] == 2
fetched = await api.get_draft_workspace(
workspace_id="binding_ws",
include_draft=True,
)
assert fetched["draft"]["steps"]["echo"]["input"] == [
{"path": "input.extra", "target": "extra"}
]
assert fetched["draft"]["steps"]["echo"]["output"] == [
{"source": "echoed", "target": "state.echoed"}
]
@pytest.mark.asyncio
async def test_remove_missing_draft_element_is_noop(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_noop")
api, _service, authoring = _draft_api(artifact_store)
await api.create_draft_workspace(
workspace_id="noop_ws",
draft={
"name": "noop_ws",
"start": "echo",
"steps": {
"echo": {"use": "demo.personal.echo_tool", "input": [], "output": []}
},
"routes": {"echo": {"ok": "__end__"}},
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
},
)
result = await authoring.remove_draft_route(
workspace_id="noop_ws",
revision=1,
step_id="echo",
outcome="missing",
)
assert result["revision"] == 1
@pytest.mark.asyncio
async def test_compile_draft_workspace_returns_compiled_plan(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_compile")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="compile_me",
draft=_echo_draft(),
)
before = await api.get_draft_workspace(
workspace_id="compile_me", include_draft=True
)
result = await api.compile_draft_workspace(workspace_id="compile_me")
after = await api.get_draft_workspace(workspace_id="compile_me", include_draft=True)
assert result["compiled_plan"]["name"] == "echo"
assert result["required_capabilities"]
assert after == before
@pytest.mark.asyncio
async def test_compile_draft_workspace_invalid_returns_diagnostics(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_compile_invalid")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
draft = _echo_draft()
draft["routes"]["echo"] = {"typo": "__end__"}
await api.create_draft_workspace(
workspace_id="invalid_ws",
draft=draft,
)
result = await api.compile_draft_workspace(workspace_id="invalid_ws")
assert result["status"] == "invalid"
assert "compiled_plan" not in result
assert result["diagnostics"]
@pytest.mark.asyncio
async def test_set_workflow_output_map_replaces_top_level_output(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_out_replace")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(workspace_id="report", draft=_echo_draft())
result = await api.set_workflow_output_map(
workspace_id="report",
revision=1,
output_map={"state.echoed": "echoed"},
)
assert result["revision"] == 2
assert result["status"] == "valid", result["diagnostics"]
fetched = await api.get_draft_workspace(workspace_id="report", include_draft=True)
assert fetched["draft"]["output"] == [{"path": "state.echoed", "target": "echoed"}]
@pytest.mark.asyncio
async def test_set_workflow_output_map_merges_top_level_output(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_out_merge")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
draft = {
**_echo_draft(),
"state_schema": {
"fields": {
"echoed": {"type": "string"},
"other": {"type": "string"},
}
},
"output_schema": {
"type": "object",
"properties": {
"echoed": {"type": "string"},
"kind": {"type": "string"},
"other": {"type": "string"},
},
},
"output": [
{"path": "state.echoed", "target": "echoed"},
{"value": "report", "target": "kind"},
],
}
await api.create_draft_workspace(workspace_id="report", draft=draft)
result = await api.set_workflow_output_map(
workspace_id="report",
revision=1,
output_map={"state.other": "other"},
merge=True,
)
assert result["status"] == "valid", result["diagnostics"]
fetched = await api.get_draft_workspace(workspace_id="report", include_draft=True)
assert fetched["draft"]["output"] == [
{"path": "state.echoed", "target": "echoed"},
{"value": "report", "target": "kind"},
{"path": "state.other", "target": "other"},
]
@pytest.mark.asyncio
async def test_set_workflow_output_map_projects_missing_output_schema(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_out_project")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
draft = {
**_echo_draft(),
"state_schema": {
"type": "object",
"properties": {
"echoed": {"type": "string"},
"after": {"type": "object"},
},
},
"output_schema": {"type": "object", "properties": {}},
"output": [],
}
await api.create_draft_workspace(workspace_id="report", draft=draft)
result = await api.set_workflow_output_map(
workspace_id="report",
revision=1,
output_map={"state.after": "after"},
merge=True,
)
assert result["status"] == "valid", result["diagnostics"]
fetched = await api.get_draft_workspace(workspace_id="report", include_draft=True)
assert fetched["draft"]["output"] == [{"path": "state.after", "target": "after"}]
assert fetched["draft"]["output_schema"]["properties"]["after"] == {
"type": "object"
}
# -- Browser-click test helpers for forward-route tests --
class _OpenClickPageInput(BaseModel):
open_browser: bool = False
class _OpenClickPageOutput(BaseModel):
before: dict = {}
session_id: str = ""
class _WaitForClickInput(BaseModel):
session_id: str
simulate: dict
timeout_seconds: int
class _WaitForClickOutput(BaseModel):
after: dict = {}
class _CollectSnapshotsInput(BaseModel):
session_id: str
before: dict
after: dict
class _CollectSnapshotsOutput(BaseModel):
before: dict = {}
after: dict = {}
@node(name="open_click_page", outcomes=("ok",))
def _open_click_page(payload: _OpenClickPageInput) -> _OpenClickPageOutput:
return _OpenClickPageOutput(before={}, session_id="")
@node(name="wait_for_click", outcomes=("ok",))
def _wait_for_click(payload: _WaitForClickInput) -> _WaitForClickOutput:
return _WaitForClickOutput(after={})
@node(name="collect_snapshots", outcomes=("ok",))
def _collect_snapshots(payload: _CollectSnapshotsInput) -> _CollectSnapshotsOutput:
return _CollectSnapshotsOutput(before={}, after={})
def _browser_click_api(
artifact_store: FileWorkflowArtifactStore,
) -> tuple[WorkflowApi, WfMcpService]:
mcp_root = artifact_store.root / "browser_mcp" / str(id(artifact_store))
service = WfMcpService(
store=FileStore(mcp_root),
artifact_store=artifact_store,
draft_workspace_store=FileDraftWorkspaceStore(mcp_root),
)
service.register_connection(
ConnectionConfig(
id="local.browser_click", server="local", account="browser_click"
)
)
service.register_specs(
"local.browser_click",
_open_click_page,
_wait_for_click,
_collect_snapshots,
)
context = context_from_service(service)
return WorkflowApi(context), service
@pytest.mark.asyncio
async def test_create_draft_from_capability_does_not_bind_optional_inputs(
tmp_path: Path,
) -> None:
api, _service = _browser_click_api(
FileWorkflowArtifactStore(tmp_path / "drafts_required_inputs")
)
created = await api.create_draft_workspace_from_capability(
workspace_id="browser",
capability_name="local.browser_click.open_click_page",
name="browser",
)
workspace = await api.get_draft_workspace(
workspace_id="browser",
include_draft=True,
)
assert created["wrapper_hints"]["input_map"] == {}
assert workspace["draft"]["steps"]["call"]["input"] == []
assert any("open_browser" in note for note in created["wrapper_hints"]["notes"])
@pytest.mark.asyncio
async def test_add_step_projects_explicit_optional_workflow_inputs(
tmp_path: Path,
) -> None:
api, _service = _browser_click_api(
FileWorkflowArtifactStore(tmp_path / "drafts_explicit_optional_input")
)
await api.create_draft_workspace_from_capability(
workspace_id="browser",
capability_name="local.browser_click.open_click_page",
name="browser",
)
result = await api.add_step_from_capability(
workspace_id="browser",
revision=1,
step_id="wait",
capability_name="local.browser_click.wait_for_click",
route_from_step="call",
routes={"ok": "__end__"},
input_map={
"state.session_id": "session_id",
"input.simulate": "simulate",
"input.timeout_seconds": "timeout_seconds",
},
bind_outputs={"after": "state.after"},
)
assert result["status"] == "valid", result["diagnostics"]
workspace = await api.get_draft_workspace(
workspace_id="browser", include_draft=True
)
properties = workspace["draft"]["input_schema"]["properties"]
assert properties["simulate"]["type"] == "object"
assert properties["timeout_seconds"]["type"] == "integer"
@pytest.mark.asyncio
async def test_add_step_persists_invalid_forward_route(tmp_path: Path) -> None:
api, _service = _browser_click_api(
FileWorkflowArtifactStore(tmp_path / "drafts_forward_route")
)
await api.create_draft_workspace_from_capability(
workspace_id="browser",
capability_name="local.browser_click.open_click_page",
name="browser",
)
# Use only state.session_id — simulate/timeout_seconds are not declared
# in the workflow input schema from open_click_page.
result = await api.add_step_from_capability(
workspace_id="browser",
revision=1,
step_id="wait",
capability_name="local.browser_click.wait_for_click",
route_from_step="call",
routes={"ok": "collect"},
input_map={
"state.session_id": "session_id",
},
bind_outputs={"after": "state.after"},
)
assert result["revision"] == 2
assert result["status"] == "invalid"
assert any(
item["code"] == "unknown_edge_destination" for item in result["diagnostics"]
)
stored = await api.get_draft_workspace(
workspace_id="browser",
include_draft=True,
)
assert (
stored["draft"]["steps"]["wait"]["use"] == "local.browser_click.wait_for_click"
)
assert stored["draft"]["routes"]["wait"]["ok"] == "collect"
@pytest.mark.asyncio
async def test_invalid_forward_route_cannot_compile_or_save(tmp_path: Path) -> None:
api, _service = _browser_click_api(
FileWorkflowArtifactStore(tmp_path / "drafts_compile_boundary")
)
await api.create_draft_workspace_from_capability(
workspace_id="browser",
capability_name="local.browser_click.open_click_page",
name="browser",
)
await api.add_step_from_capability(
workspace_id="browser",
revision=1,
step_id="wait",
capability_name="local.browser_click.wait_for_click",
route_from_step="call",
routes={"ok": "collect"},
input_map={"state.session_id": "session_id"},
bind_outputs={"after": "state.after"},
)
compiled = await api.compile_draft_workspace(workspace_id="browser")
assert compiled["status"] == "invalid"
assert any(
item["code"] == "unknown_edge_destination" for item in compiled["diagnostics"]
)
saved = await api.create_artifact_from_workspace(
workspace_id="browser",
artifact_id="browser_workflow",
version=1,
title="Browser Workflow",
outcomes=["ok"],
)
assert saved["status"] == "invalid"
assert saved["saved"] is False
assert any(
item["code"] == "unknown_edge_destination" for item in saved["diagnostics"]
)
@pytest.mark.asyncio
async def test_forward_route_becomes_valid_after_target_step_is_added(
tmp_path: Path,
) -> None:
api, _service = _browser_click_api(
FileWorkflowArtifactStore(tmp_path / "drafts_repair_path")
)
await api.create_draft_workspace_from_capability(
workspace_id="browser",
capability_name="local.browser_click.open_click_page",
name="browser",
)
await api.add_step_from_capability(
workspace_id="browser",
revision=1,
step_id="wait",
capability_name="local.browser_click.wait_for_click",
route_from_step="call",
routes={"ok": "collect"},
input_map={
"state.session_id": "session_id",
},
bind_outputs={"after": "state.after"},
)
add_collect = await api.add_step_from_capability(
workspace_id="browser",
revision=2,
step_id="collect",
capability_name="local.browser_click.collect_snapshots",
route_from_step="wait",
input_map={
"state.session_id": "session_id",
"state.before": "before",
"state.after": "after",
},
bind_outputs={"before": "state.final_before", "after": "state.final_after"},
)
assert add_collect["revision"] == 3
validated = await api.validate_draft_workspace(workspace_id="browser")
assert validated["status"] == "valid"
@pytest.mark.asyncio
async def test_bind_draft_local_output_to_workflow_output_lowers_through_state(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(
tmp_path / "drafts_bind_output_workflow_output"
)
api, service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="report",
draft={
"name": "report",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "render",
"steps": {
"render": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [],
}
},
"routes": {"render": {"ok": "__end__"}},
},
)
result = await authoring.bind_draft(
workspace_id="report",
revision=1,
step_id="render",
source_path="local.echoed",
target_path="output.echoed",
)
assert result["status"] == "valid"
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
assert workspace["draft"]["steps"]["render"]["output"] == [
{"source": "echoed", "target": "state.echoed"}
]
assert workspace["draft"]["output"] == [
{"path": "state.echoed", "target": "echoed"}
]
assert (
workspace["draft"]["state_schema"]["properties"]["echoed"]["type"] == "string"
)
assert (
workspace["draft"]["output_schema"]["properties"]["echoed"]["type"] == "string"
)
@pytest.mark.asyncio
async def test_bind_draft_local_output_to_workflow_output_reuses_compatible_state(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(
tmp_path / "drafts_bind_existing_output_state"
)
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="report",
draft={
"name": "report",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {
"type": "object",
"properties": {
"echoed": {
"description": "Echoed text",
"title": "Echoed",
"type": "string",
}
},
},
"output_schema": {"type": "object", "properties": {}},
"start": "render",
"steps": {
"render": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [{"source": "echoed", "target": "state.echoed"}],
}
},
"routes": {"render": {"ok": "__end__"}},
},
)
result = await authoring.bind_draft(
workspace_id="report",
revision=1,
step_id="render",
source_path="local.echoed",
target_path="output.echoed",
)
assert result["status"] == "valid"
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
assert workspace["draft"]["steps"]["render"]["output"] == [
{"source": "echoed", "target": "state.echoed"}
]
assert workspace["draft"]["output"] == [
{"path": "state.echoed", "target": "echoed"}
]
@pytest.mark.asyncio
async def test_bind_draft_local_output_to_state_reuses_compatible_state(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_existing_state")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
draft = _echo_draft()
draft["state_schema"] = {
"type": "object",
"properties": {
"echoed": {
"description": "Echoed text",
"title": "Echoed",
"type": "string",
}
},
}
await api.create_draft_workspace(workspace_id="report", draft=draft)
result = await authoring.bind_draft(
workspace_id="report",
revision=1,
step_id="echo",
source_path="local.echoed",
target_path="state.echoed",
)
assert result["status"] == "valid", result["diagnostics"]
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
assert workspace["draft"]["steps"]["echo"]["output"] == [
{"source": "echoed", "target": "state.echoed"}
]
@pytest.mark.asyncio
async def test_bind_draft_local_output_to_quoted_workflow_output_field(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_quoted_output")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
draft = _echo_draft()
draft["state_schema"] = {"type": "object", "properties": {}}
draft["output_schema"] = {"type": "object", "properties": {}}
draft["steps"]["echo"]["output"] = []
await api.create_draft_workspace(workspace_id="quoted", draft=draft)
result = await authoring.bind_draft(
workspace_id="quoted",
revision=1,
step_id="echo",
source_path="local.echoed",
target_path='output."public.echoed"',
)
assert result["status"] == "valid"
workspace = await api.get_draft_workspace(workspace_id="quoted", include_draft=True)
assert workspace["draft"]["steps"]["echo"]["output"] == [
{"source": "echoed", "target": 'state."public.echoed"'}
]
assert workspace["draft"]["output"] == [
{"path": 'state."public.echoed"', "target": '"public.echoed"'}
]
@pytest.mark.asyncio
async def test_bind_draft_replaces_previous_public_output_for_local_field(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_rebind_output")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
draft = _echo_draft()
draft["state_schema"] = {
"type": "object",
"properties": {
"old": {"type": "string"},
},
}
draft["output_schema"] = {
"type": "object",
"properties": {
"old": {"type": "string"},
},
}
draft["steps"]["echo"]["output"] = [{"source": "echoed", "target": "state.old"}]
draft["output"] = [{"path": "state.old", "target": "old"}]
await api.create_draft_workspace(workspace_id="report", draft=draft)
result = await authoring.bind_draft(
workspace_id="report",
revision=1,
step_id="echo",
source_path="local.echoed",
target_path="output.echoed",
)
assert result["status"] == "valid"
workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True)
assert workspace["draft"]["output"] == [
{"path": "state.echoed", "target": "echoed"}
]
@pytest.mark.asyncio
async def test_remove_draft_binding_rejects_non_object_entries(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bad_bindings")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
draft = _echo_draft()
draft["steps"]["echo"]["input"] = ["not-an-object"]
await api.create_draft_workspace(workspace_id="bad", draft=draft)
with pytest.raises(ValueError, match="input binding entries.*objects"):
await authoring.remove_draft_binding(
workspace_id="bad",
revision=1,
step_id="echo",
inputs=["text"],
)
@pytest.mark.asyncio
async def test_validate_draft_workspace_omits_unusable_nested_input_repair_hint(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(
tmp_path / "drafts_nested_input_schema_hint"
)
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="nested_ws",
draft={
"name": "nested",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "call",
"steps": {
"call": {
"use": "demo.personal.echo_tool",
"input": [{"target": "payload.text", "path": "input.undeclared"}],
"output": [],
}
},
"routes": {"call": {"ok": "__end__"}},
},
)
payload = await api.validate_draft_workspace(workspace_id="nested_ws")
diagnostic = next(
item for item in payload["diagnostics"] if item["code"] == "invalid_source_path"
)
assert "repair_hint" not in diagnostic
@pytest.mark.asyncio
async def test_validate_draft_workspace_details_invalid_input_source_path(
tmp_path: Path,
) -> None:
"""invalid_source_path on a step input must carry step_id, source_path, target_field."""
artifact_store = FileWorkflowArtifactStore(
tmp_path / "drafts_invalid_source_details"
)
api, service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="wait_ws",
draft={
"name": "wait",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "wait",
"steps": {
"wait": {
"use": "demo.personal.echo_tool",
"input": [
{
"target": "text",
"path": "input.undeclared",
}
],
"output": [],
}
},
"routes": {"wait": {"ok": "__end__"}},
},
)
payload = await api.validate_draft_workspace(workspace_id="wait_ws")
diagnostic = payload["diagnostics"][0]
assert diagnostic["code"] == "invalid_source_path"
assert diagnostic["step_id"] == "wait"
assert diagnostic["details"]["source_path"] == "input.undeclared"
assert diagnostic["details"]["target_field"] == "text"
@pytest.mark.asyncio
async def test_validate_draft_workspace_hints_input_schema_projection(
tmp_path: Path,
) -> None:
"""invalid_source_path with input.* source must produce a repair_hint."""
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_input_schema_hint")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="wait_ws",
draft={
"name": "wait",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "wait",
"steps": {
"wait": {
"use": "demo.personal.echo_tool",
"input": [
{
"target": "text",
"path": "input.undeclared",
}
],
"output": [],
}
},
"routes": {"wait": {"ok": "__end__"}},
},
)
payload = await api.validate_draft_workspace(workspace_id="wait_ws")
diagnostic = payload["diagnostics"][0]
assert diagnostic["code"] == "invalid_source_path"
assert diagnostic["step_id"] == "wait"
assert diagnostic["repair_hint"] == (
"wf draft bind wait_ws --revision 1 "
"--step wait --from input.undeclared --to local.text"
)
@pytest.mark.asyncio
async def test_validate_draft_workspace_hints_state_schema_projection(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_state_schema_hint")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="wait_ws",
draft={
"name": "wait",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "wait",
"steps": {
"wait": {
"use": "demo.personal.echo_tool",
"input": [{"target": "text", "path": "state.undeclared"}],
"output": [],
}
},
"routes": {"wait": {"ok": "__end__"}},
},
)
payload = await api.validate_draft_workspace(workspace_id="wait_ws")
diagnostic = next(
item for item in payload["diagnostics"] if item["code"] == "invalid_source_path"
)
assert diagnostic["repair_hint"] == (
"wf draft bind wait_ws --revision 1 "
"--step wait --from state.undeclared --to local.text"
)