Skip to content

Commit 18926e8

Browse files
authored
fix(acp): await task/create instead of backgrounding it (#519)
1 parent 761833e commit 18926e8

2 files changed

Lines changed: 199 additions & 0 deletions

File tree

‎src/agentex/protocol/acp.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,23 @@ class InterruptTaskParams(BaseModel):
127127
)
128128

129129

130+
# Methods whose handler must finish before the RPC responds.
131+
#
132+
# TASK_CREATE is here because its whole contract is to hand back an id for a
133+
# task that now exists. Its handler starts the Temporal workflow, so answering
134+
# before the handler runs returns an id the server cannot yet route to: a caller
135+
# that follows task/create with event/send can have its signal arrive first and
136+
# be dropped with `workflow not found`. Nothing batches task creations, so there
137+
# is no benefit to trade against, and start_workflow is a short RPC.
138+
#
139+
# EVENT_SEND deliberately stays asynchronous. Callers send events in quick
140+
# succession and the workflow drains them as a batch; awaiting each send
141+
# serialises them and no batch ever holds more than one event. Once TASK_CREATE
142+
# is synchronous the workflow is addressable before any event is sent, which is
143+
# what the race needed.
130144
RPC_SYNC_METHODS = [
131145
RPCMethod.MESSAGE_SEND,
146+
RPCMethod.TASK_CREATE,
132147
]
133148

134149
PARAMS_MODEL_BY_METHOD: dict[RPCMethod, type[BaseModel]] = {

‎tests/test_acp_sync_dispatch.py‎

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""Unit tests for the dispatch mode of ``task/create`` and ``event/send``.
2+
3+
``task/create`` hands back an id for a task that now exists, and its handler is
4+
what starts the Temporal workflow. Dispatching it in the background answered
5+
before the handler ran, so the id named a workflow the server could not yet
6+
route to. A caller that followed ``task/create`` with ``event/send`` could have
7+
its signal arrive first and be dropped with ``workflow not found``, and it saw
8+
success either way because the handler's exception was raised into a background
9+
task that only logged it.
10+
11+
``event/send`` keeps its background dispatch on purpose: callers send events in
12+
quick succession and the workflow drains them as a batch, which awaiting each
13+
send would serialise. These tests pin both halves so neither is changed by
14+
accident:
15+
16+
1. ``task/create`` finishes its handler before responding, and surfaces a
17+
handler failure as a JSON-RPC error.
18+
2. ``event/send`` still acknowledges immediately without waiting.
19+
20+
``SyncACP`` is used as the transport because it constructs with no Temporal
21+
connection and no network, and the dispatch under test lives in the shared
22+
``BaseACPServer``.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import asyncio
28+
from typing import Any
29+
30+
import pytest
31+
32+
from agentex.types.task import Task
33+
from agentex.types.agent import Agent
34+
from agentex.types.event import Event
35+
from agentex.protocol.acp import (
36+
RPC_SYNC_METHODS,
37+
RPCMethod,
38+
SendEventParams,
39+
CreateTaskParams,
40+
)
41+
from agentex.protocol.json_rpc import JSONRPCResponse
42+
from agentex.lib.sdk.fastacp.impl.sync_acp import SyncACP
43+
44+
45+
def _agent() -> Agent:
46+
return Agent(
47+
id="test-agent-456",
48+
name="test-agent",
49+
description="test-agent",
50+
acp_type="async",
51+
created_at="2023-01-01T00:00:00Z",
52+
updated_at="2023-01-01T00:00:00Z",
53+
)
54+
55+
56+
def _task() -> Task:
57+
return Task(id="test-task-123", status="RUNNING")
58+
59+
60+
def _event() -> Event:
61+
return Event(
62+
id="test-event-789",
63+
agent_id="test-agent-456",
64+
sequence_id=1,
65+
task_id="test-task-123",
66+
)
67+
68+
69+
def _create_params() -> CreateTaskParams:
70+
return CreateTaskParams(agent=_agent(), task=_task(), params=None)
71+
72+
73+
def _event_params() -> SendEventParams:
74+
return SendEventParams(agent=_agent(), task=_task(), event=_event())
75+
76+
77+
class _FakeRequest:
78+
"""The slice of ``starlette.requests.Request`` that ``_handle_jsonrpc`` uses."""
79+
80+
def __init__(self, payload: dict[str, Any]) -> None:
81+
self._payload = payload
82+
self.headers: dict[str, str] = {}
83+
84+
async def json(self) -> dict[str, Any]:
85+
return self._payload
86+
87+
88+
def _rpc(method: RPCMethod, params: Any) -> dict[str, Any]:
89+
return {
90+
"jsonrpc": "2.0",
91+
"method": method.value,
92+
"params": params.model_dump(mode="json"),
93+
"id": f"{method.value}-test",
94+
}
95+
96+
97+
async def _dispatch(acp: SyncACP, method: RPCMethod, params: Any) -> JSONRPCResponse:
98+
"""Call the JSON-RPC entry point and narrow its untyped return."""
99+
response = await acp._handle_jsonrpc(_FakeRequest(_rpc(method, params))) # pyright: ignore[reportArgumentType]
100+
assert isinstance(response, JSONRPCResponse)
101+
return response
102+
103+
104+
# ---------------------------------------------------------------------------
105+
# Which methods are synchronous
106+
# ---------------------------------------------------------------------------
107+
108+
109+
class TestSyncMethodSet:
110+
@pytest.mark.parametrize("method", [RPCMethod.MESSAGE_SEND, RPCMethod.TASK_CREATE])
111+
def test_method_is_synchronous(self, method: RPCMethod) -> None:
112+
assert method in RPC_SYNC_METHODS
113+
114+
def test_event_send_stays_asynchronous(self) -> None:
115+
# Batching depends on it: callers send events in quick succession and the
116+
# workflow drains them together. Awaiting each send serialises them.
117+
assert RPCMethod.EVENT_SEND not in RPC_SYNC_METHODS
118+
119+
120+
# ---------------------------------------------------------------------------
121+
# task/create: handler completes before the response
122+
# ---------------------------------------------------------------------------
123+
124+
125+
class TestTaskCreateAwaitsHandler:
126+
async def test_handler_runs_before_response(self) -> None:
127+
acp = SyncACP()
128+
order: list[str] = []
129+
130+
@acp.on_task_create
131+
async def handler(params: CreateTaskParams) -> None:
132+
# Yield control so a backgrounded handler would lose the race and
133+
# let the response be sent first.
134+
await asyncio.sleep(0)
135+
order.append("handler")
136+
137+
response = await _dispatch(acp, RPCMethod.TASK_CREATE, _create_params())
138+
order.append("response")
139+
140+
assert order == ["handler", "response"]
141+
assert response.error is None
142+
assert response.result != {"status": "processing"}
143+
144+
async def test_handler_failure_returns_error(self) -> None:
145+
acp = SyncACP()
146+
147+
@acp.on_task_create
148+
async def handler(params: CreateTaskParams) -> None:
149+
raise RuntimeError("workflow not found for ID: test-task-123")
150+
151+
response = await _dispatch(acp, RPCMethod.TASK_CREATE, _create_params())
152+
153+
assert response.error is not None
154+
assert "workflow not found" in response.error.message
155+
156+
157+
# ---------------------------------------------------------------------------
158+
# event/send: acknowledges without waiting
159+
# ---------------------------------------------------------------------------
160+
161+
162+
class TestEventSendIsBackgrounded:
163+
async def test_response_does_not_wait_for_handler(self) -> None:
164+
acp = SyncACP()
165+
started = asyncio.Event()
166+
order: list[str] = []
167+
168+
@acp.on_task_event_send
169+
async def handler(params: SendEventParams) -> None:
170+
await asyncio.sleep(0)
171+
order.append("handler")
172+
started.set()
173+
174+
response = await _dispatch(acp, RPCMethod.EVENT_SEND, _event_params())
175+
order.append("response")
176+
177+
# The acknowledgment comes back before the handler has run, which is what
178+
# lets a caller enqueue several events without waiting on each one.
179+
assert order == ["response"]
180+
assert response.result == {"status": "processing"}
181+
182+
# The handler still runs, just afterwards.
183+
await asyncio.wait_for(started.wait(), timeout=5)
184+
assert order == ["response", "handler"]

0 commit comments

Comments
 (0)