Skip to content

Commit c582066

Browse files
committed
test(acp): pin synchronous dispatch for task/create and event/send
Two properties the fix depends on, exercised through SyncACP so the test needs no Temporal connection and no network: - the handler finishes before the response is sent, so a caller that sequences task/create then event/send gets the ordering it asked for - a handler that raises produces a JSON-RPC error rather than a success, so the failure is visible and retryable at the client Each async handler yields with `await asyncio.sleep(0)` first, so a regression back to background dispatch loses the ordering race and fails the test.
1 parent da17925 commit c582066

1 file changed

Lines changed: 186 additions & 0 deletions

File tree

‎tests/test_acp_sync_dispatch.py‎

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""Unit tests for synchronous dispatch of ``task/create`` and ``event/send``.
2+
3+
Background dispatch of these two methods loses work silently. The ACP server
4+
used to answer ``{"status": "processing"}`` before running the handler, so a
5+
caller doing ``task/create`` then ``event/send`` could have its signal reach
6+
Temporal before the workflow existed. The signal was dropped with
7+
``workflow not found``, and the caller saw success either way because the
8+
handler's exception was raised into a background task that only logged it.
9+
10+
These tests pin the two properties that fix depends on:
11+
12+
1. The response is not sent until the handler has finished, so a caller that
13+
sequences two calls gets the ordering it asked for.
14+
2. A handler that raises produces a JSON-RPC error, so the failure is visible
15+
and retryable at the client.
16+
17+
``SyncACP`` is used as the transport because it constructs with no Temporal
18+
connection and no network, and the dispatch under test lives in the shared
19+
``BaseACPServer``.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import asyncio
25+
from typing import Any
26+
27+
import pytest
28+
29+
from agentex.types.task import Task
30+
from agentex.types.agent import Agent
31+
from agentex.types.event import Event
32+
from agentex.protocol.acp import (
33+
RPC_SYNC_METHODS,
34+
RPCMethod,
35+
SendEventParams,
36+
CreateTaskParams,
37+
)
38+
from agentex.lib.sdk.fastacp.impl.sync_acp import SyncACP
39+
40+
41+
def _agent() -> Agent:
42+
return Agent(
43+
id="test-agent-456",
44+
name="test-agent",
45+
description="test-agent",
46+
acp_type="async",
47+
created_at="2023-01-01T00:00:00Z",
48+
updated_at="2023-01-01T00:00:00Z",
49+
)
50+
51+
52+
def _task() -> Task:
53+
return Task(id="test-task-123", status="RUNNING")
54+
55+
56+
def _event() -> Event:
57+
return Event(
58+
id="test-event-789",
59+
agent_id="test-agent-456",
60+
sequence_id=1,
61+
task_id="test-task-123",
62+
)
63+
64+
65+
class _FakeRequest:
66+
"""The slice of ``starlette.requests.Request`` that ``_handle_jsonrpc`` uses."""
67+
68+
def __init__(self, payload: dict[str, Any]) -> None:
69+
self._payload = payload
70+
self.headers: dict[str, str] = {}
71+
72+
async def json(self) -> dict[str, Any]:
73+
return self._payload
74+
75+
76+
def _rpc(method: RPCMethod, params: Any) -> dict[str, Any]:
77+
return {
78+
"jsonrpc": "2.0",
79+
"method": method.value,
80+
"params": params.model_dump(mode="json"),
81+
"id": f"{method.value}-test",
82+
}
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# Protocol
87+
# ---------------------------------------------------------------------------
88+
89+
90+
class TestSyncMethodSet:
91+
@pytest.mark.parametrize(
92+
"method",
93+
[RPCMethod.MESSAGE_SEND, RPCMethod.TASK_CREATE, RPCMethod.EVENT_SEND],
94+
)
95+
def test_method_is_synchronous(self, method: RPCMethod) -> None:
96+
assert method in RPC_SYNC_METHODS
97+
98+
99+
# ---------------------------------------------------------------------------
100+
# Dispatch ordering
101+
# ---------------------------------------------------------------------------
102+
103+
104+
class TestHandlerCompletesBeforeResponse:
105+
async def test_task_create_awaits_handler(self) -> None:
106+
acp = SyncACP()
107+
order: list[str] = []
108+
109+
@acp.on_task_create
110+
async def handler(params: CreateTaskParams) -> None:
111+
# Yield control so a backgrounded handler would lose the race and
112+
# let the response be sent first.
113+
await asyncio.sleep(0)
114+
order.append("handler")
115+
116+
response = await acp._handle_jsonrpc(
117+
_FakeRequest(_rpc(RPCMethod.TASK_CREATE, CreateTaskParams(agent=_agent(), task=_task())))
118+
)
119+
order.append("response")
120+
121+
assert order == ["handler", "response"]
122+
assert response.error is None
123+
assert response.result != {"status": "processing"}
124+
125+
async def test_event_send_awaits_handler(self) -> None:
126+
acp = SyncACP()
127+
order: list[str] = []
128+
129+
@acp.on_task_event_send
130+
async def handler(params: SendEventParams) -> None:
131+
await asyncio.sleep(0)
132+
order.append("handler")
133+
134+
response = await acp._handle_jsonrpc(
135+
_FakeRequest(
136+
_rpc(
137+
RPCMethod.EVENT_SEND,
138+
SendEventParams(agent=_agent(), task=_task(), event=_event()),
139+
)
140+
)
141+
)
142+
order.append("response")
143+
144+
assert order == ["handler", "response"]
145+
assert response.error is None
146+
assert response.result != {"status": "processing"}
147+
148+
149+
# ---------------------------------------------------------------------------
150+
# Failure visibility
151+
# ---------------------------------------------------------------------------
152+
153+
154+
class TestHandlerFailureReachesCaller:
155+
async def test_task_create_failure_returns_error(self) -> None:
156+
acp = SyncACP()
157+
158+
@acp.on_task_create
159+
async def handler(params: CreateTaskParams) -> None:
160+
raise RuntimeError("workflow not found for ID: test-task-123")
161+
162+
response = await acp._handle_jsonrpc(
163+
_FakeRequest(_rpc(RPCMethod.TASK_CREATE, CreateTaskParams(agent=_agent(), task=_task())))
164+
)
165+
166+
assert response.error is not None
167+
assert "workflow not found" in response.error["message"]
168+
169+
async def test_event_send_failure_returns_error(self) -> None:
170+
acp = SyncACP()
171+
172+
@acp.on_task_event_send
173+
async def handler(params: SendEventParams) -> None:
174+
raise RuntimeError("workflow not found for ID: test-task-123")
175+
176+
response = await acp._handle_jsonrpc(
177+
_FakeRequest(
178+
_rpc(
179+
RPCMethod.EVENT_SEND,
180+
SendEventParams(agent=_agent(), task=_task(), event=_event()),
181+
)
182+
)
183+
)
184+
185+
assert response.error is not None
186+
assert "workflow not found" in response.error["message"]

0 commit comments

Comments
 (0)