feat: add callable remote workflow capabilities
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from wf_client import App, CapabilitySummary, Page
|
||||
from wf_client.protocols import WorkflowClientPort
|
||||
from wf_platform import CapabilityRef
|
||||
|
||||
|
||||
def _inspect_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"name": "app.default.search",
|
||||
"source_id": "app.default",
|
||||
"kind": "node_spec",
|
||||
"description": "Search things",
|
||||
"outcomes": ["ok"],
|
||||
"is_async": False,
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
"output_schema": {"type": "object", "properties": {}},
|
||||
"wrapper_hints": {
|
||||
"capability_name": "app.default.search",
|
||||
"confidence": "high",
|
||||
"declared_outcomes": ["ok"],
|
||||
"suggested_wrapper_outcomes": ["ok"],
|
||||
"outcome_policy": "preserve_declared",
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
"state_schema": {"type": "object", "properties": {}},
|
||||
"output_schema": {"type": "object", "properties": {}},
|
||||
"input_map": {},
|
||||
"output_map": {},
|
||||
"outcome_candidates": [],
|
||||
"missing_decisions": [],
|
||||
"notes": [],
|
||||
},
|
||||
"accepts_context": False,
|
||||
}
|
||||
|
||||
|
||||
class _Port:
|
||||
def __init__(self, *, capability_name: str = "app.default.search") -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
self.capability_name = capability_name
|
||||
|
||||
async def inspect_capability(self, **params: Any) -> object:
|
||||
self.calls.append(("inspect", params))
|
||||
payload = _inspect_payload()
|
||||
payload["name"] = self.capability_name
|
||||
payload["wrapper_hints"]["capability_name"] = self.capability_name
|
||||
return payload
|
||||
|
||||
async def list_capabilities(self, **params: Any) -> object:
|
||||
self.calls.append(("list", params))
|
||||
return {
|
||||
"next_cursor": None,
|
||||
"total": 1,
|
||||
"capabilities": [
|
||||
{
|
||||
"name": "app.default.search",
|
||||
"source_id": "app.default",
|
||||
"kind": "node_spec",
|
||||
"description": "Search things",
|
||||
"outcomes": ["ok"],
|
||||
"is_async": False,
|
||||
"input_fields": [],
|
||||
"output_fields": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _app(*, capability_name: str = "app.default.search") -> App:
|
||||
return App._from_port(
|
||||
cast(WorkflowClientPort, _Port(capability_name=capability_name))
|
||||
)
|
||||
|
||||
|
||||
def test_from_http_jsonrpc_is_lazy(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
httpx.AsyncClient,
|
||||
"post",
|
||||
lambda *args, **kwargs: calls.append("post"),
|
||||
)
|
||||
|
||||
app = App.from_http_jsonrpc("http://localhost:8765/rpc")
|
||||
|
||||
assert app.endpoint == "http://localhost:8765/rpc"
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capability_discovery_returns_rich_page() -> None:
|
||||
page = await _app().capabilities(query="search", limit=10)
|
||||
|
||||
assert isinstance(page, Page)
|
||||
assert page.total == 1
|
||||
assert page.next_cursor is None
|
||||
assert isinstance(page.items[0], CapabilitySummary)
|
||||
assert page.items[0].qualified_name == "app.default.search"
|
||||
assert page.items[0].outcomes == ("ok",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capability_reconstructs_structural_reference() -> None:
|
||||
capability = await _app().capability("app.default.search")
|
||||
|
||||
assert capability.ref == CapabilityRef.parse("app.default.search")
|
||||
assert capability.ref.source.parts == ("app", "default")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capability_reference_keeps_dotted_local_key() -> None:
|
||||
capability = await _app(capability_name="app.default.search.v2").capability(
|
||||
"app.default.search.v2"
|
||||
)
|
||||
|
||||
assert capability.ref.source.parts == ("app", "default")
|
||||
assert capability.ref.name == "search.v2"
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from wf_client import CapabilityResult, RemoteCapability
|
||||
from wf_client.errors import InvalidResponse
|
||||
from wf_client.protocols import WorkflowClientPort
|
||||
from wf_platform import CapabilityRef, SourceRef
|
||||
|
||||
|
||||
def _inspect_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"name": "app.default.search",
|
||||
"source_id": "app.default",
|
||||
"kind": "node_spec",
|
||||
"description": "Search things",
|
||||
"outcomes": ["ok", "error"],
|
||||
"is_async": True,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"results": {"type": "array"}},
|
||||
"required": ["results"],
|
||||
},
|
||||
"wrapper_hints": {},
|
||||
"accepts_context": False,
|
||||
}
|
||||
|
||||
|
||||
class _Port:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
async def call_capability(self, **params: Any) -> object:
|
||||
self.calls.append(params)
|
||||
return {
|
||||
"qualified_name": "app.default.search",
|
||||
"source_id": "app.default",
|
||||
"kind": "node_spec",
|
||||
"deployment_id": None,
|
||||
"outcome": "ok",
|
||||
"output": {"results": ["one"]},
|
||||
"diagnostics": [],
|
||||
}
|
||||
|
||||
|
||||
def _port() -> WorkflowClientPort:
|
||||
return cast(WorkflowClientPort, _Port())
|
||||
|
||||
|
||||
def test_remote_capability_preserves_dotted_local_key() -> None:
|
||||
capability = RemoteCapability(
|
||||
_port=_port(),
|
||||
ref=CapabilityRef(source=SourceRef.parse("app.default"), name="search.v2"),
|
||||
qualified_name="app.default.search.v2",
|
||||
description=None,
|
||||
input_schema={"type": "object"},
|
||||
output_schema={"type": "object"},
|
||||
outcomes=("ok",),
|
||||
is_async=False,
|
||||
)
|
||||
|
||||
assert capability.ref.name == "search.v2"
|
||||
assert capability.node_def().name == "app.default.search.v2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_capability_is_callable_and_validates_result() -> None:
|
||||
port = _Port()
|
||||
capability = RemoteCapability(
|
||||
_port=cast(WorkflowClientPort, port),
|
||||
ref=CapabilityRef.parse("app.default.search"),
|
||||
qualified_name="app.default.search",
|
||||
description=None,
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
output_schema={"type": "object", "required": ["results"]},
|
||||
outcomes=("ok",),
|
||||
is_async=False,
|
||||
)
|
||||
|
||||
result = await capability(query="workflow")
|
||||
|
||||
assert isinstance(result, CapabilityResult)
|
||||
assert result.output == {"results": ["one"]}
|
||||
assert port.calls == [
|
||||
{
|
||||
"qualified_name": "app.default.search",
|
||||
"payload": {"query": "workflow"},
|
||||
"deployment_id": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_capability_rejects_mixed_payload_forms() -> None:
|
||||
capability = RemoteCapability(
|
||||
_port=_port(),
|
||||
ref=CapabilityRef.parse("app.default.search"),
|
||||
qualified_name="app.default.search",
|
||||
description=None,
|
||||
input_schema={"type": "object"},
|
||||
output_schema={"type": "object"},
|
||||
outcomes=("ok",),
|
||||
is_async=False,
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="not both"):
|
||||
await capability({"query": "workflow"}, query="again")
|
||||
|
||||
|
||||
def test_remote_capability_rejects_invalid_inspected_schema() -> None:
|
||||
with pytest.raises(InvalidResponse, match="invalid JSON Schema"):
|
||||
RemoteCapability(
|
||||
_port=_port(),
|
||||
ref=CapabilityRef.parse("app.default.search"),
|
||||
qualified_name="app.default.search",
|
||||
description=None,
|
||||
input_schema={"type": "not-a-json-schema-type"},
|
||||
output_schema={"type": "object"},
|
||||
outcomes=("ok",),
|
||||
is_async=False,
|
||||
)
|
||||
Reference in New Issue
Block a user