fix(mcp ux): even more error handling
who asked blud to conventional commits
This commit is contained in:
@@ -60,6 +60,11 @@ def mcp_echo_tool(payload: ChangedEchoInput) -> ChangedEchoOutput:
|
||||
return ChangedEchoOutput(echoed=payload.message)
|
||||
|
||||
|
||||
@node(name="failing_tool")
|
||||
def failing_tool(payload: ChangedEchoInput) -> ChangedEchoOutput:
|
||||
raise RuntimeError("upstream exploded")
|
||||
|
||||
|
||||
@reducer(name="custom.default.multiply")
|
||||
def multiply(current: int | None, incoming: int) -> int:
|
||||
return (current or 1) * incoming
|
||||
@@ -162,6 +167,37 @@ def test_workflow_surface_filters_stdlib_capabilities_by_source() -> None:
|
||||
assert payload["capabilities"][0]["source_id"] == "wf.std"
|
||||
|
||||
|
||||
def test_workflow_surface_call_capability_returns_structured_error() -> None:
|
||||
service = WfMcpService(
|
||||
store=FileStore(local_temp_root() / "surface_call_capability_error_mcp"),
|
||||
artifact_store=FileWorkflowArtifactStore(
|
||||
local_temp_root() / "surface_call_capability_error"
|
||||
),
|
||||
)
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_specs("demo.personal", failing_tool)
|
||||
handlers = WorkflowSurfaceHandlers(service)
|
||||
|
||||
payload = asyncio.run(
|
||||
handlers.call_capability(
|
||||
qualified_name="demo.personal.failing_tool",
|
||||
payload={"message": "hello"},
|
||||
)
|
||||
)
|
||||
|
||||
assert payload["qualified_name"] == "demo.personal.failing_tool"
|
||||
assert payload["source_id"] == "demo.personal"
|
||||
assert payload["kind"] == "node_spec"
|
||||
assert payload["outcome"] == "runtime_error"
|
||||
assert payload["output"] is None
|
||||
assert payload["diagnostics"][0]["code"] == "capability_call_failed"
|
||||
assert payload["diagnostics"][0]["severity"] == "error"
|
||||
assert "demo.personal.failing_tool" in payload["diagnostics"][0]["message"]
|
||||
assert "upstream exploded" in payload["diagnostics"][0]["message"]
|
||||
|
||||
|
||||
def test_workflow_surface_lists_saved_wrapper_capabilities() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "surface_wrapper_caps"
|
||||
@@ -738,7 +774,7 @@ def test_workflow_surface_creates_minimal_draft_workspace_with_error_route() ->
|
||||
|
||||
result = asyncio.run(
|
||||
handlers.create_minimal_draft_workspace(
|
||||
workspace_id="echo_draft_canonical_error",
|
||||
workspace_id="echo_draft_static_error",
|
||||
name="echo",
|
||||
capability_name="demo.personal.mcp_echo_tool",
|
||||
input_schema={
|
||||
@@ -757,18 +793,53 @@ def test_workflow_surface_creates_minimal_draft_workspace_with_error_route() ->
|
||||
)
|
||||
)
|
||||
assert service.draft_workspace_store is not None
|
||||
workspace = service.draft_workspace_store.get_workspace(
|
||||
"echo_draft_canonical_error"
|
||||
)
|
||||
workspace = service.draft_workspace_store.get_workspace("echo_draft_static_error")
|
||||
|
||||
assert result["workspace_id"] == "echo_draft_canonical_error"
|
||||
assert result["workspace_id"] == "echo_draft_static_error"
|
||||
assert workspace.draft["routes"]["call"]["ok"] == "__end__"
|
||||
assert workspace.draft["routes"]["call"]["error"] == "tool_error"
|
||||
assert workspace.draft["steps"]["tool_error"]["use"] == "wf.std.runtime_error"
|
||||
assert workspace.draft["steps"]["tool_error"]["input"] == [
|
||||
{
|
||||
"target": {"root": "local", "parts": ["message"]},
|
||||
"path": {"root": "state", "parts": ["echoed"]},
|
||||
"value": "Capability call failed",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_workflow_surface_minimal_draft_honors_explicit_error_message_source() -> None:
|
||||
service = WfMcpService(
|
||||
store=FileStore(local_temp_root() / "surface_minimal_explicit_error_mcp"),
|
||||
artifact_store=FileWorkflowArtifactStore(
|
||||
local_temp_root() / "surface_minimal_explicit_error"
|
||||
),
|
||||
)
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_specs("demo.personal", mcp_echo_tool)
|
||||
handlers = WorkflowSurfaceHandlers(service)
|
||||
|
||||
asyncio.run(
|
||||
handlers.create_minimal_draft_workspace(
|
||||
workspace_id="echo_draft_explicit_error",
|
||||
name="echo",
|
||||
capability_name="demo.personal.mcp_echo_tool",
|
||||
input_schema={"type": "object"},
|
||||
state_schema={"fields": {"error_message": {"type": "string"}}},
|
||||
output_schema={"type": "object"},
|
||||
input_map={"input.text": "text"},
|
||||
output_map={"echoed": "state.echoed"},
|
||||
error_message_source="state.error_message",
|
||||
)
|
||||
)
|
||||
assert service.draft_workspace_store is not None
|
||||
workspace = service.draft_workspace_store.get_workspace("echo_draft_explicit_error")
|
||||
|
||||
assert workspace.draft["steps"]["tool_error"]["input"] == [
|
||||
{
|
||||
"target": {"root": "local", "parts": ["message"]},
|
||||
"path": {"root": "state", "parts": ["error_message"]},
|
||||
}
|
||||
]
|
||||
|
||||
@@ -900,6 +971,49 @@ def test_workflow_surface_creates_artifact_from_workspace() -> None:
|
||||
artifact = artifact_store.get_artifact("workspace_echo", 1)
|
||||
assert result["saved"] is True
|
||||
assert artifact.id == "workspace_echo"
|
||||
assert artifact.plan["nodes"][0]["node"] == "demo.echo_tool"
|
||||
required = artifact.required_capability_map()["demo.echo_tool"]
|
||||
assert required.kind == "node_spec"
|
||||
assert str(required.observed_concrete_source) == "demo.personal"
|
||||
assert required.input_schema_snapshot is not None
|
||||
assert required.output_schema_snapshot is not None
|
||||
|
||||
|
||||
def test_workflow_surface_workspace_artifact_infers_raw_concrete_dependency() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "surface_workspace_artifact_raw_dependency"
|
||||
)
|
||||
service = WfMcpService(
|
||||
store=FileStore(local_temp_root() / "surface_workspace_artifact_raw_mcp"),
|
||||
artifact_store=artifact_store,
|
||||
)
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_specs("demo.personal", echo_tool)
|
||||
handlers = WorkflowSurfaceHandlers(service)
|
||||
asyncio.run(
|
||||
handlers.create_draft_workspace(
|
||||
workspace_id="echo_draft",
|
||||
draft=_echo_draft(),
|
||||
)
|
||||
)
|
||||
|
||||
asyncio.run(
|
||||
handlers.create_artifact_from_workspace(
|
||||
workspace_id="echo_draft",
|
||||
artifact_id="workspace_echo_raw_dependency",
|
||||
version=1,
|
||||
title="Workspace Echo Raw Dependency",
|
||||
outcomes=("completed",),
|
||||
)
|
||||
)
|
||||
|
||||
artifact = artifact_store.get_artifact("workspace_echo_raw_dependency", 1)
|
||||
required = artifact.required_capability_map()["demo.personal.echo_tool"]
|
||||
assert required.kind == "node_spec"
|
||||
assert required.input_schema_snapshot is not None
|
||||
assert required.output_schema_snapshot is not None
|
||||
|
||||
|
||||
def test_workflow_surface_creates_wrapper_from_workspace() -> None:
|
||||
@@ -1000,6 +1114,44 @@ def test_workflow_surface_runs_non_interrupting_deployment() -> None:
|
||||
assert traced["trace_truncated"] is False
|
||||
|
||||
|
||||
def test_workflow_surface_failed_deployment_exposes_error_on_run_and_inspect() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "surface_failed_run_error"
|
||||
)
|
||||
artifact_store.save_artifact(_failing_artifact())
|
||||
artifact_store.save_deployment(
|
||||
WorkflowDeployment(
|
||||
id="fail.personal",
|
||||
artifact_id="fail",
|
||||
artifact_version=1,
|
||||
bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
|
||||
)
|
||||
)
|
||||
service = WfMcpService(
|
||||
store=FileStore(local_temp_root() / "surface_failed_run_error_mcp"),
|
||||
artifact_store=artifact_store,
|
||||
)
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_specs("demo.personal", failing_tool)
|
||||
handlers = WorkflowSurfaceHandlers(service)
|
||||
|
||||
payload = asyncio.run(
|
||||
handlers.run_deployment(
|
||||
deployment_id="fail.personal",
|
||||
workflow_input={"message": "hello"},
|
||||
)
|
||||
)
|
||||
inspected = asyncio.run(handlers.inspect_run(run_id=payload["run_id"]))
|
||||
|
||||
assert payload["status"] == "failed"
|
||||
assert "upstream exploded" in payload["error"]
|
||||
assert payload["trace_count"] == 0
|
||||
assert inspected["status"] == "failed"
|
||||
assert inspected["error"] == payload["error"]
|
||||
|
||||
|
||||
def test_workflow_surface_run_deployment_can_include_trace_detail() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "surface_run_trace_detail"
|
||||
@@ -1507,6 +1659,48 @@ def _logical_echo_artifact() -> WorkflowArtifact:
|
||||
)
|
||||
|
||||
|
||||
def _failing_artifact() -> WorkflowArtifact:
|
||||
plan: dict[str, Any] = {
|
||||
"name": "fail",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"message": {"type": "string"}},
|
||||
"required": ["message"],
|
||||
},
|
||||
"state_schema": {"fields": {"echoed": {"type": "string"}}},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"echoed": {"type": "string"}},
|
||||
},
|
||||
"start": "fail",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "fail",
|
||||
"type": "node",
|
||||
"node": "demo.personal.failing_tool",
|
||||
"input": [input_binding("input.message", "message")],
|
||||
"output": [output_binding("echoed", "state.echoed")],
|
||||
}
|
||||
],
|
||||
"edges": [{"from": "fail", "outcome": "ok", "to": "__end__"}],
|
||||
}
|
||||
return WorkflowArtifact(
|
||||
id="fail",
|
||||
version=1,
|
||||
title="Fail",
|
||||
input_schema=plan["input_schema"],
|
||||
output_schema=plan["output_schema"],
|
||||
outcomes=("completed",),
|
||||
plan=plan,
|
||||
required_capabilities={
|
||||
"demo.failing_tool": RequiredCapability(
|
||||
ref="demo.failing_tool",
|
||||
kind="node_spec",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _custom_reducer_artifact() -> WorkflowArtifact:
|
||||
plan: dict[str, Any] = {
|
||||
"name": "multiply",
|
||||
|
||||
Reference in New Issue
Block a user