From 3d679c488b591272b4552045d932195ac6d70bc1 Mon Sep 17 00:00:00 2001 From: enginerd-kr Date: Sat, 4 Jul 2026 16:05:47 +0900 Subject: [PATCH] fix(claude-agent-sdk): use msg_id for tool-call parent_message_id on fallback path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-streaming fallback branch (how a programmatic subagent's own turn arrives, as a complete AssistantMessage) computes the correct assistant message id (msg_id) to build/upsert that message, but passes the stale current_message_id — already None on this path — into handle_tool_use_block(), so the tool call's TOOL_CALL_START carries no parent_message_id. Fixes #2118 --- .../python/ag_ui_claude_sdk/adapter.py | 7 ++++-- .../python/tests/test_adapter.py | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/adapter.py b/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/adapter.py index 08ec9d95bf..6fbe5eb1f0 100644 --- a/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/adapter.py +++ b/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/adapter.py @@ -977,8 +977,11 @@ def flush_pending_msg(): # Handle complete messages if isinstance(message, (AssistantMessage, UserMessage)): + # msg_id (used below and passed to handle_tool_use_block()) — + # not current_message_id, which is only valid inside the + # streaming loop and is already None here. + msg_id = current_message_id or str(uuid.uuid4()) if isinstance(message, AssistantMessage): - msg_id = current_message_id or str(uuid.uuid4()) agui_msg = build_agui_assistant_message(message, msg_id) if agui_msg: upsert_message(agui_msg) @@ -991,7 +994,7 @@ def flush_pending_msg(): continue updated_state, tool_events = await handle_tool_use_block( block, message, thread_id, run_id, self._per_thread_state.get(thread_id), - parent_message_id=current_message_id, + parent_message_id=msg_id, ) if tool_id: processed_tool_ids.add(tool_id) diff --git a/integrations/claude-agent-sdk/python/tests/test_adapter.py b/integrations/claude-agent-sdk/python/tests/test_adapter.py index 6b12ac091c..dfadc25c27 100644 --- a/integrations/claude-agent-sdk/python/tests/test_adapter.py +++ b/integrations/claude-agent-sdk/python/tests/test_adapter.py @@ -18,6 +18,8 @@ from ag_ui_claude_sdk.utils import extract_tool_names +from claude_agent_sdk import AssistantMessage, ToolUseBlock + from .conftest import stream_event, aiter @@ -109,6 +111,29 @@ async def test_backend_tool_call_sequence(self, make_input): # exactly one END for the one tool call assert types.count(EventType.TOOL_CALL_END) == 1 + @pytest.mark.asyncio + async def test_subagent_tool_call_gets_parent_message_id(self, make_input): + # A complete AssistantMessage, not a StreamEvent, is how a subagent's + # own turn arrives — this hits the fallback branch (#2118). + adapter = ClaudeAgentAdapter(name="t") + subagent_message = AssistantMessage( + content=[ToolUseBlock(id="tc-subagent-1", name="mcp__ui__render_ui", input={})], + model="claude-haiku-4-5", + parent_tool_use_id="tc-agent-call", + ) + events = await _drive(adapter, [subagent_message], make_input) + types = _types(events) + assert EventType.TOOL_CALL_START in types + start = next(e for e in events if e.type == EventType.TOOL_CALL_START) + assert start.tool_call_name == "render_ui" # prefix stripped + assert start.parent_message_id is not None + assert start.parent_message_id != "tc-agent-call" # not the SDK's parent_tool_use_id + + # The id must also be the one reported in the run's MESSAGES_SNAPSHOT, + # i.e. it's a real, stable message id, not an arbitrary placeholder. + snapshot = next(e for e in events if e.type == EventType.MESSAGES_SNAPSHOT) + assert any(getattr(m, "id", None) == start.parent_message_id for m in snapshot.messages) + @pytest.mark.asyncio async def test_frontend_tool_halts_stream(self, make_input): adapter = ClaudeAgentAdapter(name="t")