From b31c0e180351bd761983c285ab0c031ce67b0364 Mon Sep 17 00:00:00 2001 From: hallerite Date: Thu, 6 Aug 2026 01:37:48 +0200 Subject: [PATCH] Handle late ACP session updates --- tests/v1/test_e2e.py | 3 +- verifiers/v1/acp/runner.py | 56 ++++++++++++++++++++++++++------------ 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/tests/v1/test_e2e.py b/tests/v1/test_e2e.py index de4960c39b..6e67cd4dd7 100644 --- a/tests/v1/test_e2e.py +++ b/tests/v1/test_e2e.py @@ -231,11 +231,12 @@ async def test_acp_resume_with_tool(run_v1, harness, harness_runtime, tmp_path): assert trace.ok, trace.errors assert trace.stop_condition == "user_closed" assert trace.rewards["resumed"].score == 1.0 - assert trace.tools # ACP-native tools, or Pi's MCP adapter meta-tool segments = trace.info["acp_segments"] assert len(segments) == 2 assert segments[0]["terminated"] is False assert segments[1]["terminated"] is False + # Native MCP tools need not appear in the intercepted model request that + # populates trace.tools; the ACP transcript is the source of truth for use. assert "tool" in segments[1]["roles"] assert segments[1]["tool_outputs"] if harness == "rlm": diff --git a/verifiers/v1/acp/runner.py b/verifiers/v1/acp/runner.py index eb0adcf460..405c757725 100644 --- a/verifiers/v1/acp/runner.py +++ b/verifiers/v1/acp/runner.py @@ -36,6 +36,7 @@ ) MAX_PACKET_BYTES = 128 * 1024 * 1024 +LATE_UPDATE_GRACE_SECONDS = 1.0 class VerifiersACPClient(Client): @@ -43,6 +44,7 @@ def __init__(self) -> None: self.visible_reply = "" self.message_id: str | None = None self.tool_calls: dict[str, str] = {} + self.output_changed = asyncio.Condition() def reset(self) -> None: self.visible_reply = "" @@ -50,22 +52,23 @@ def reset(self) -> None: self.tool_calls = {} async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None: - if isinstance(update, ToolCall): - self.tool_calls[update.tool_call_id] = update.status or "pending" - return - if isinstance(update, ToolCallUpdate): - if update.status: - self.tool_calls[update.tool_call_id] = update.status - return - if not isinstance(update, AgentMessageChunk) or not isinstance( - update.content, TextContentBlock - ): - return - message_id = getattr(update, "message_id", None) - if message_id is not None and message_id != self.message_id: - self.visible_reply = "" - self.message_id = message_id - self.visible_reply += update.content.text + async with self.output_changed: + if isinstance(update, ToolCall): + self.tool_calls[update.tool_call_id] = update.status or "pending" + elif isinstance(update, ToolCallUpdate): + if update.status: + self.tool_calls[update.tool_call_id] = update.status + elif isinstance(update, AgentMessageChunk) and isinstance( + update.content, TextContentBlock + ): + message_id = getattr(update, "message_id", None) + if message_id is not None and message_id != self.message_id: + self.visible_reply = "" + self.message_id = message_id + self.visible_reply += update.content.text + else: + return + self.output_changed.notify_all() async def request_permission( self, @@ -172,6 +175,25 @@ async def prompt( except RequestError as error: detail = error.data.get("details") if isinstance(error.data, dict) else None raise RuntimeError(detail or str(error)) from error + + # ACP 0.11 dispatches notifications in background tasks but resolves a request + # response directly in its receive loop. An agent that sends its final + # session/update immediately before session/prompt returns can therefore wake + # this coroutine before the update handler has run. Wait specifically for text: + # a completed tool update may also arrive first and must not hide a later reply. + def has_visible_reply() -> bool: + return bool(client.visible_reply.strip()) + + if not has_visible_reply(): + async with client.output_changed: + try: + await asyncio.wait_for( + client.output_changed.wait_for(has_visible_reply), + timeout=LATE_UPDATE_GRACE_SECONDS, + ) + except asyncio.TimeoutError: # noqa: UP041 - Python 3.10 compatibility + pass + tool_statuses = list(client.tool_calls.values()) completed_tool_turn = ( config.get("allow_empty_tool_reply", False) @@ -179,7 +201,7 @@ async def prompt( and bool(tool_statuses) and all(status in ("completed", "failed") for status in tool_statuses) ) - if not client.visible_reply.strip() and not completed_tool_turn: + if not has_visible_reply() and not completed_tool_turn: raise RuntimeError( "ACP agent produced no visible reply " f"(stop_reason={response.stop_reason}, tool_statuses={tool_statuses})"