From 5d2ce68f6b66d71ba9fb4bad16d25d4e2e0029f2 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 28 Aug 2026 17:01:00 +0200 Subject: [PATCH] feat(core): publish tool.result on the bus for the activity panel --- src/beaver_gateway/core/conversations.py | 34 ++++++++++++++++++++ tests/test_conversations.py | 40 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/beaver_gateway/core/conversations.py b/src/beaver_gateway/core/conversations.py index 0ecf0dd..6803fc3 100644 --- a/src/beaver_gateway/core/conversations.py +++ b/src/beaver_gateway/core/conversations.py @@ -29,7 +29,9 @@ from claude_agent_sdk import ( AssistantMessage, ResultMessage, StreamEvent, + ToolResultBlock, ToolUseBlock, + UserMessage, fork_session_via_store, project_key_for_directory, ) @@ -963,6 +965,20 @@ class Conversations: name=block.name, input=block.input, ) + elif isinstance(message, UserMessage): + blocks = message.content if isinstance(message.content, list) else () + for block in blocks: + if isinstance(block, ToolResultBlock): + self._bus.publish( + "tool.result", + conversation_id=conversation_id, + turn_id=turn_id, + origin=origin, + parent_tool_use_id=parent, + tool_use_id=block.tool_use_id, + is_error=bool(block.is_error), + content=_result_preview(block.content), + ) elif isinstance(message, ResultMessage) and parent is None: self._bus.publish( "result", @@ -1055,6 +1071,24 @@ def _iso(value: datetime | None) -> str | None: return _aware(value).isoformat(timespec="seconds") if value is not None else None +def _result_preview( + content: str | list[dict[str, Any]] | None, limit: int = 400 +) -> str: + """Short text of a tool result for the panel: the transcript keeps the whole.""" + if content is None: + return "" + text = ( + content + if isinstance(content, str) + else "\n".join( + str(part.get("text", "")) + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ) + ) + return text if len(text) <= limit else text[:limit] + "…" + + def _usage_dict(capture: TurnCapture) -> dict[str, Any] | None: usage = capture.usage if usage is None: diff --git a/tests/test_conversations.py b/tests/test_conversations.py index 3842db5..dc4667a 100644 --- a/tests/test_conversations.py +++ b/tests/test_conversations.py @@ -12,6 +12,9 @@ from claude_agent_sdk import ( ResultMessage, StreamEvent, TextBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, project_key_for_directory, ) @@ -655,3 +658,40 @@ def test_agent_kinds_follow_prompts(tmp_path: Path) -> None: kinds=("master", "deep"), prompts=Prompts(master=()), ) + + +async def test_observer_publishes_tool_results_for_the_panel(world: World) -> None: + seen: list[dict[str, Any]] = [] + + async def collect() -> None: + async for event in world.bus.stream(conversation_id="c1"): + seen.append(event) + + task = asyncio.create_task(collect()) + await asyncio.sleep(0) + observe = world.conversations._observer("c1", "t1", "user") + observe( + AssistantMessage( + content=[ToolUseBlock(id="toolu_1", name="Bash", input={"command": "ls"})], + model="m", + parent_tool_use_id="toolu_0", + ) + ) + observe( + UserMessage( + content=[ + ToolResultBlock(tool_use_id="toolu_1", content="a\nb", is_error=False) + ], + parent_tool_use_id="toolu_0", + ) + ) + observe(UserMessage(content="plain text, no tool result")) + await asyncio.sleep(0.05) + task.cancel() + assert [e["type"] for e in seen] == ["tool", "tool.result"] + tool, result = seen + assert tool["tool_use_id"] == "toolu_1" and tool["parent_tool_use_id"] == "toolu_0" + assert result["tool_use_id"] == "toolu_1" + assert result["parent_tool_use_id"] == "toolu_0" + assert result["turn_id"] == "t1" and result["is_error"] is False + assert result["content"] == "a\nb"