From 7bdbab4f1848df64bac43e4a128a4abc6cbd2f9b Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 20:54:59 +0300 Subject: [PATCH 1/4] feat: Report Claude SDK native tools and approval waits as activity A native tool's streamed tool_use shows its name until its tool_result, then reasoning; Band MCP tools already report through dispatch_tool and are skipped. A manual approval shows permission until the room has no unanswered approval left. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- src/band/adapters/claude_sdk.py | 79 ++++++++++++------- src/band/testing/activity.py | 6 ++ .../adapters/claude_sdk/activity/__init__.py | 1 + .../adapters/claude_sdk/activity/conftest.py | 10 +++ tests/adapters/claude_sdk/activity/kit.py | 23 ++++++ .../claude_sdk/activity/test_native_tools.py | 23 ++++++ .../claude_sdk/activity/test_permission.py | 36 +++++++++ .../claude_sdk/test_dedup_activity.py | 46 +++++++++++ 8 files changed, 196 insertions(+), 28 deletions(-) create mode 100644 tests/adapters/claude_sdk/activity/__init__.py create mode 100644 tests/adapters/claude_sdk/activity/conftest.py create mode 100644 tests/adapters/claude_sdk/activity/kit.py create mode 100644 tests/adapters/claude_sdk/activity/test_native_tools.py create mode 100644 tests/adapters/claude_sdk/activity/test_permission.py create mode 100644 tests/integrations/claude_sdk/test_dedup_activity.py diff --git a/src/band/adapters/claude_sdk.py b/src/band/adapters/claude_sdk.py index 04e5f6ab2..7554ae43f 100644 --- a/src/band/adapters/claude_sdk.py +++ b/src/band/adapters/claude_sdk.py @@ -131,6 +131,7 @@ BandMCPTransport, SharedBandMCPBackend, ) +from band.runtime.activity import set_permission_wait from band.runtime.custom_tools import ( CustomToolDef, custom_tool_effects, @@ -142,6 +143,7 @@ ) from band.runtime.formatters import format_tokens, strip_leading_mentions from band.runtime.prompts import SYSTEM_UPDATE_PREFIX +from band.runtime.toolactivity import end_tool_activity, report_tool_activity from band.runtime.tools import ( ALL_TOOL_NAMES, BAND_MCP_SERVER_NAME, @@ -469,6 +471,12 @@ class ClaudeSDKCommand(StrEnum): # Approvals only gate Claude Code's side-effecting native tools. NATIVE_TOOL_MATCHER = rf"^(?!{re.escape(MCP_TOOL_PREFIX)}|{re.escape(TOOL_SEARCH)}$).+" + +def _served_by_band(sdk_tool_name: str) -> bool: + """Band's MCP server reports its own tools' activity as they run.""" + return sdk_tool_name.startswith(MCP_TOOL_PREFIX) + + # Patterns that look like secrets/tokens in shell commands _REDACT_RE = re.compile( r"""(?x) @@ -675,7 +683,7 @@ def __init__( # _declined_the_reply for what this is cross-referenced against and # why tool_use_id uniqueness needs no turn-scoping here. self._notified_declines: dict[str, set[str]] = {} - # Bare tool name per pending call, keyed by room then tool_use_id + # SDK tool name per pending call, keyed by room then tool_use_id # (ToolResultBlock only carries the id). Room-scoped rather than # turn-local: a resumed session can replay a result whose tool_use # streamed in an earlier, truncated turn, and that result must still @@ -1405,7 +1413,9 @@ async def _on_tool_use( # Bare name for the cross-adapter tool_call record (the SDK # namespaces our tools mcp__band__*; see _semantic_tool_name). tool_name = self._semantic_tool_name(block.name) - pending_tool_names[block.id] = tool_name + pending_tool_names[block.id] = block.name + if not _served_by_band(block.name): + await report_tool_activity(tools, tool_name) await self._narrate_tool_call(block, tool_name, room_id, tools) async def _narrate_tool_call( @@ -1555,7 +1565,12 @@ async def _on_tool_result( Shared by both envelopes a result can arrive in: user-type messages (the protocol shape) and assistant messages (accepted defensively). """ - result_tool_name = pending_tool_names.pop(block.tool_use_id, None) + sdk_tool_name = pending_tool_names.pop(block.tool_use_id, None) + if sdk_tool_name is not None and not _served_by_band(sdk_tool_name): + await end_tool_activity(tools) + result_tool_name = ( + None if sdk_tool_name is None else self._semantic_tool_name(sdk_tool_name) + ) await self._narrate_tool_result(block, result_tool_name, room_id, tools) async def _narrate_tool_result( @@ -1858,36 +1873,41 @@ async def _resolve_manual_approval( # so the caller isn't left waiting for a timeout nobody will see. tools = self._room_tools.get(room_id) mention = [requester["id"]] if requester else None - if tools: - try: - await tools.send_notice( - APPROVAL_REQUESTED_TEMPLATE.format(summary=summary, token=token), - mentions=mention, - ) - except Exception: # noqa: BLE001 -- tool calls may raise any exception type; must surface to the LLM as an error string, not crash the turn - logger.warning("Room %s: Failed to send approval notification", room_id) - # A reply that claimed it meanwhile owns the answer; otherwise - # decline without a notice, so the missing-reply guard stays. - if registry.withdraw(entry): - self._clear_pending_approval(room_id, entry) - return PermissionResultDeny( - message="Could not deliver approval prompt, tool use declined" + await set_permission_wait(tools, True) + try: + if tools: + try: + await tools.send_notice( + APPROVAL_REQUESTED_TEMPLATE.format( + summary=summary, token=token + ), + mentions=mention, ) + except Exception: # noqa: BLE001 -- tool calls may raise any exception type; must surface to the LLM as an error string, not crash the turn + logger.warning( + "Room %s: Failed to send approval notification", room_id + ) + # A reply that claimed it meanwhile owns the answer; otherwise + # decline without a notice, so the missing-reply guard stays. + if registry.withdraw(entry): + return PermissionResultDeny( + message="Could not deliver approval prompt, tool use declined" + ) + + # The request has been posted (or there was nowhere to post it) -- + # either way, on_message must return now so Band's room loop can + # dispatch the reply that will resolve this wait. The turn is then + # judged at its real end (see _on_turn_complete). + if tools: + tools.turn.detach() + self._release_turn(room_id) - # The request has been posted (or there was nowhere to post it) -- - # either way, on_message must return now so Band's room loop can - # dispatch the reply that will resolve this wait. The turn is then - # judged at its real end (see _on_turn_complete). - if tools: - tools.turn.detach() - self._release_turn(room_id) - - try: decision = await registry.wait( entry, pending.future, timeout_s=self._approvals.wait_timeout_s ) finally: self._clear_pending_approval(room_id, entry) + await set_permission_wait(tools, self._open_approval_count(room_id) > 0) match decision: case Timeout.TIMED_OUT: @@ -2062,8 +2082,7 @@ async def _handle_status_command( session_count = manager.get_session_count() if manager else 0 session = manager.session(room_id) if manager else None session_id = (session.session_id if session else None) or "—" - registry = self._pending_approvals.get(room_id) - pending_count = registry.unclaimed_count() if registry else 0 + pending_count = self._open_approval_count(room_id) lines = [ "**Claude SDK Status**", @@ -2120,6 +2139,10 @@ def _next_approval_token(self, room_id: str) -> str: def _new_approval_registry(self) -> DecisionRegistry[PendingApproval]: return DecisionRegistry(max_pending=self._approvals.max_pending_per_room) + def _open_approval_count(self, room_id: str) -> int: + registry = self._pending_approvals.get(room_id) + return registry.unclaimed_count() if registry else 0 + def _clear_pending_approval( self, room_id: str, entry: DecisionEntry[PendingApproval] ) -> None: diff --git a/src/band/testing/activity.py b/src/band/testing/activity.py index b48a93922..5af66b256 100644 --- a/src/band/testing/activity.py +++ b/src/band/testing/activity.py @@ -41,5 +41,11 @@ async def update(self, activity: AgentActivityDetail) -> None: async def set_manual_wait(self, waiting: bool) -> None: self.events.append({"waiting": waiting}) + @property + def waiting(self) -> bool: + """Whether the last reported wait says a person is being waited on.""" + waits = [event["waiting"] for event in self.events if "waiting" in event] + return bool(waits) and waits[-1] + def assert_completed_tool(self, name: str) -> None: assert self.events == [shown_tool(name), SHOWN_REASONING] diff --git a/tests/adapters/claude_sdk/activity/__init__.py b/tests/adapters/claude_sdk/activity/__init__.py new file mode 100644 index 000000000..2e3a109bf --- /dev/null +++ b/tests/adapters/claude_sdk/activity/__init__.py @@ -0,0 +1 @@ +"""What a Claude SDK room's turn reports as its activity.""" diff --git a/tests/adapters/claude_sdk/activity/conftest.py b/tests/adapters/claude_sdk/activity/conftest.py new file mode 100644 index 000000000..181460f2c --- /dev/null +++ b/tests/adapters/claude_sdk/activity/conftest.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from band.testing.activity import ActivityRecorder + + +@pytest.fixture +def recorder() -> ActivityRecorder: + return ActivityRecorder() diff --git a/tests/adapters/claude_sdk/activity/kit.py b/tests/adapters/claude_sdk/activity/kit.py new file mode 100644 index 000000000..417a043d0 --- /dev/null +++ b/tests/adapters/claude_sdk/activity/kit.py @@ -0,0 +1,23 @@ +"""A turn that replies over Band's MCP server, then runs a native tool.""" + +from __future__ import annotations + +from typing import Any + +from band.runtime.tools.types import BandTool +from band.testing.activity import SHOWN_REASONING, shown_tool +from tests.adapters.claude_sdk.helpers import ClaudeRoom +from tests.baseline.decisions import ModelDecision + +# The reply's MCP call ends before the CLI streams the Bash call, so the order +# is fixed. +REPLY_THEN_BASH_ACTIVITY: list[dict[str, Any]] = [ + shown_tool(BandTool.SEND_MESSAGE), + SHOWN_REASONING, + shown_tool("Bash"), + SHOWN_REASONING, +] + + +def reply_then_bash(room: ClaudeRoom) -> list[ModelDecision]: + return [room.model_reply("Listing."), ModelDecision.call("Bash", command="ls")] diff --git a/tests/adapters/claude_sdk/activity/test_native_tools.py b/tests/adapters/claude_sdk/activity/test_native_tools.py new file mode 100644 index 000000000..d1371ee0e --- /dev/null +++ b/tests/adapters/claude_sdk/activity/test_native_tools.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from band.testing.activity import ActivityRecorder +from tests.adapters.claude_sdk.activity.kit import ( + REPLY_THEN_BASH_ACTIVITY, + reply_then_bash, +) +from tests.adapters.claude_sdk.helpers import OpenRoom + + +class TestClaudeNativeToolActivity: + async def test_a_native_tool_shows_until_its_result_and_band_tools_show_once( + self, claude_room: OpenRoom, recorder: ActivityRecorder + ) -> None: + """The reply's MCP call reports itself, so its streamed tool_use does + not report it again; a native tool reports from the stream.""" + room = await claude_room() + recorder.observing(room.tools) + room.claude.script(reply_then_bash(room)) + + await room.send("List the files") + + assert recorder.events == REPLY_THEN_BASH_ACTIVITY diff --git a/tests/adapters/claude_sdk/activity/test_permission.py b/tests/adapters/claude_sdk/activity/test_permission.py new file mode 100644 index 000000000..ab268e052 --- /dev/null +++ b/tests/adapters/claude_sdk/activity/test_permission.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from band.adapters.claude_sdk import APPROVAL_RESOLVED_TEMPLATE +from band.testing.activity import ActivityRecorder +from tests.adapters.claude_sdk.helpers import OpenRoom, with_approvals +from tests.baseline.decisions import ModelDecision, ToolCall + +BASH_AND_WRITE = ModelDecision( + tool_calls=( + ToolCall("Bash", {"command": "ls"}), + ToolCall("Write", {"file_path": "notes.md", "content": "todo"}), + ) +) + + +class TestClaudePermissionActivity: + async def test_the_room_shows_permission_until_its_last_approval_is_answered( + self, claude_room: OpenRoom, recorder: ActivityRecorder + ) -> None: + room = await claude_room(with_approvals()) + recorder.observing(room.tools) + room.claude.script([BASH_AND_WRITE, room.model_reply("Done.")]) + + await room.send("List the files and jot a note") + await room.until_said("Approval requested", times=2) + assert recorder.waiting + + await room.send("/approve a-1") + await room.until_said( + APPROVAL_RESOLVED_TEMPLATE.format(token="a-1", decision="accept") + ) + assert recorder.waiting + + await room.send("/approve a-2") + await room.settled() + assert not recorder.waiting diff --git a/tests/integrations/claude_sdk/test_dedup_activity.py b/tests/integrations/claude_sdk/test_dedup_activity.py new file mode 100644 index 000000000..16f57e0d9 --- /dev/null +++ b/tests/integrations/claude_sdk/test_dedup_activity.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import asyncio +from typing import Any, cast +from unittest.mock import MagicMock + +from band.integrations.claude_sdk.dedup_tools import DedupingAgentTools +from band.runtime.tools import AgentTools +from band.runtime.tools.types import BandTool +from band.testing.activity import ActivityRecorder +from tests.adapters.activity.kit import call_over_mcp +from tests.runtime.helpers import ROOM_ID + + +class TestDedupedToolActivity: + async def test_a_tool_ends_on_the_turn_it_started_on_across_an_inner_swap( + self, mock_rest_client: MagicMock + ) -> None: + """Claude's room tools swap their inner turn tools per turn; a call + still running then must not end on the newer turn.""" + started_turn, next_turn = ActivityRecorder(), ActivityRecorder() + room_tools = DedupingAgentTools( + started_turn.observing(AgentTools(ROOM_ID, mock_rest_client)) + ) + entered, release = asyncio.Event(), asyncio.Event() + listing = mock_rest_client.agent_api_participants.list_agent_chat_participants + answer = listing.return_value + + async def held(*_args: Any, **_kwargs: Any) -> Any: + entered.set() + await release.wait() + return answer + + listing.side_effect = held + call = asyncio.create_task( + call_over_mcp(cast(AgentTools, room_tools), BandTool.GET_PARTICIPANTS, {}) + ) + await entered.wait() + await room_tools.update_inner( + next_turn.observing(AgentTools(ROOM_ID, mock_rest_client)) + ) + release.set() + await call + + started_turn.assert_completed_tool(BandTool.GET_PARTICIPANTS) + assert next_turn.events == [] From 1f77dc016a122f008f162254739115948f40df69 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 20:57:03 +0300 Subject: [PATCH 2/4] feat: Report Codex tool items, compaction and approval waits as activity A tool item shows its name from item/started to item/completed, and a contextCompaction item shows memory until it completes. Custom tools Codex asks the adapter to run show their name while they run; Band tools already report themselves. A manual approval shows permission until the room has no unanswered approval left. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- src/band/adapters/codex.py | 39 +++++++++++--- tests/adapters/codex/activity/__init__.py | 1 + tests/adapters/codex/activity/conftest.py | 10 ++++ tests/adapters/codex/activity/kit.py | 51 +++++++++++++++++++ .../codex/activity/test_compaction.py | 30 +++++++++++ .../codex/activity/test_custom_tool.py | 34 +++++++++++++ .../codex/activity/test_permission.py | 30 +++++++++++ .../codex/activity/test_tool_items.py | 40 +++++++++++++++ 8 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 tests/adapters/codex/activity/__init__.py create mode 100644 tests/adapters/codex/activity/conftest.py create mode 100644 tests/adapters/codex/activity/kit.py create mode 100644 tests/adapters/codex/activity/test_compaction.py create mode 100644 tests/adapters/codex/activity/test_custom_tool.py create mode 100644 tests/adapters/codex/activity/test_permission.py create mode 100644 tests/adapters/codex/activity/test_tool_items.py diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 3a41024fd..0be4a2098 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -101,6 +101,7 @@ build_agent_failure, parse_plan_steps, ) +from band.runtime.activity import set_permission_wait from band.runtime.custom_tools import ( CustomToolDef, custom_tool_to_openai_schema, @@ -116,6 +117,12 @@ ) from band.runtime.formatters import strip_leading_mentions from band.runtime.prompts import COMMUNICATION_INSTRUCTIONS, render_system_prompt +from band.runtime.toolactivity import ( + end_tool_activity, + report_memory_activity, + report_tool_activity, + tool_activity, +) from band.runtime.tools import ( get_band_tool_category, image_block_placeholder, @@ -292,6 +299,9 @@ class CodexSandboxMode(StrEnum): {CodexItemType.SUB_AGENT_ACTIVITY} ) +# Items that show as the turn's activity from item/started to item/completed. +_ACTIVITY_ITEM_TYPES = _TOOL_ITEM_TYPES | {CodexItemType.CONTEXT_COMPACTION} + _UNRENDERED_ITEM_TYPES = ( _REQUESTED_TOOL_ITEM_TYPES | _MESSAGE_ITEM_TYPES | _ROUTING_ITEM_TYPES ) @@ -1697,10 +1707,10 @@ async def _process_turn_events( if event.method == "item/started": item = params.get("item") - if ( - isinstance(item, dict) - and item.get("type") == CodexItemType.AGENT_MESSAGE - ): + if not isinstance(item, dict): + continue + await self._show_item_started(tools, item) + if item.get("type") == CodexItemType.AGENT_MESSAGE: result.native_messages[str(item["id"])] = NativeMessage( phase=item.get("phase"), ) @@ -1739,6 +1749,8 @@ async def _process_turn_events( if event.method == "item/completed": item = params.get("item") if isinstance(item, dict): + if item.get("type") in _ACTIVITY_ITEM_TYPES: + await end_tool_activity(tools) if item.get("type") == CodexItemType.AGENT_MESSAGE: message = result.native_messages.setdefault( str(item["id"]), @@ -2548,9 +2560,10 @@ async def _handle_server_request( try: custom_tool = find_custom_tool(self._custom_tools, tool_name) if custom_tool: - result = await execute_custom_tool( - custom_tool, arguments, turn=tools.turn - ) + async with tool_activity(tools, tool_name): + result = await execute_custom_tool( + custom_tool, arguments, turn=tools.turn + ) success = True else: # Structured: a base tool (e.g. band_send_message) can fail without @@ -2935,6 +2948,16 @@ async def _emit_item_completed_events( exc_info=True, ) + async def _show_item_started( + self, tools: AgentToolsProtocol, item: dict[str, Any] + ) -> None: + item_type = item.get("type", "") + if item_type == CodexItemType.CONTEXT_COMPACTION: + await report_memory_activity(tools) + elif item_type in _TOOL_ITEM_TYPES: + name = self._extract_tool_item(item_type, item).name + await report_tool_activity(tools, name) + async def _emit_item_event( self, *, @@ -3220,6 +3243,7 @@ async def _resolve_manual_approval( self.config.max_pending_approvals_per_room, ) try: + await set_permission_wait(tools, True) approval_msg = APPROVAL_REQUESTED_TEMPLATE.format( summary=summary, token=token ) @@ -3303,6 +3327,7 @@ async def _resolve_manual_approval( return timeout_decision finally: self._clear_pending_approval(room_id, entry) + await set_permission_wait(tools, self._open_approval_count(room_id) > 0) async def _forward_raw_task_event( self, diff --git a/tests/adapters/codex/activity/__init__.py b/tests/adapters/codex/activity/__init__.py new file mode 100644 index 000000000..9985b6dda --- /dev/null +++ b/tests/adapters/codex/activity/__init__.py @@ -0,0 +1 @@ +"""What a Codex room's turn reports as its activity.""" diff --git a/tests/adapters/codex/activity/conftest.py b/tests/adapters/codex/activity/conftest.py new file mode 100644 index 000000000..181460f2c --- /dev/null +++ b/tests/adapters/codex/activity/conftest.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from band.testing.activity import ActivityRecorder + + +@pytest.fixture +def recorder() -> ActivityRecorder: + return ActivityRecorder() diff --git a/tests/adapters/codex/activity/kit.py b/tests/adapters/codex/activity/kit.py new file mode 100644 index 000000000..b2dbd3676 --- /dev/null +++ b/tests/adapters/codex/activity/kit.py @@ -0,0 +1,51 @@ +"""Codex app-server thread items, shaped as its v2 protocol schema sends them.""" + +from __future__ import annotations + +from typing import Any + +from band.integrations.codex import RpcEvent +from band.integrations.codex.types import CodexItemType +from tests.adapters.codexturns import FIRST_THREAD_ID, event_notification + +IN_PROGRESS = "inProgress" + +LIST_FILES: dict[str, Any] = { + "type": CodexItemType.COMMAND_EXECUTION, + "id": "cmd-1", + "command": "ls", + "commandActions": [], + "cwd": "/workspace", + "status": IN_PROGRESS, +} +DOCS_LOOKUP: dict[str, Any] = { + "type": CodexItemType.MCP_TOOL_CALL, + "id": "mcp-1", + "server": "docs", + "tool": "search", + "arguments": {"query": "activity"}, + "status": IN_PROGRESS, +} +BAND_TOOL_CALL: dict[str, Any] = { + "type": CodexItemType.DYNAMIC_TOOL_CALL, + "id": "dyn-1", + "tool": "band_get_participants", + "arguments": {}, + "status": IN_PROGRESS, +} +COMPACTION: dict[str, Any] = {"type": CodexItemType.CONTEXT_COMPACTION, "id": "cc-1"} + + +def item_started(item: dict[str, Any]) -> RpcEvent: + return event_notification( + "item/started", {"threadId": FIRST_THREAD_ID, "startedAtMs": 1, "item": item} + ) + + +def item_completed(item: dict[str, Any]) -> RpcEvent: + """``item`` finished; an item with a lifecycle status completes it.""" + finished = {**item, "status": "completed"} if "status" in item else item + return event_notification( + "item/completed", + {"threadId": FIRST_THREAD_ID, "completedAtMs": 2, "item": finished}, + ) diff --git a/tests/adapters/codex/activity/test_compaction.py b/tests/adapters/codex/activity/test_compaction.py new file mode 100644 index 000000000..0131d44b9 --- /dev/null +++ b/tests/adapters/codex/activity/test_compaction.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from band.adapters.codex import CodexAdapterConfig +from band.testing.activity import SHOWN_MEMORY, SHOWN_REASONING, ActivityRecorder +from tests.adapters.codex.activity.kit import COMPACTION, item_completed, item_started +from tests.adapters.codex.kit import ToolSchemaFakeTools, run_codex_turn +from tests.adapters.codexturns import event_notification, final_text, turn_completed + + +class TestCodexCompactionActivity: + async def test_compaction_shows_memory_until_its_item_completes( + self, recorder: ActivityRecorder + ) -> None: + """The deprecated context/compacted notice that follows only posts its + task event.""" + await run_codex_turn( + events=[ + item_started(COMPACTION), + item_completed(COMPACTION), + event_notification( + "context/compacted", {"threadId": "thr-1", "turnId": "turn-1"} + ), + final_text("Done."), + turn_completed(), + ], + tools=recorder.observing(ToolSchemaFakeTools()), + config=CodexAdapterConfig(emit_turn_lifecycle_events=True), + ) + + assert recorder.events == [SHOWN_MEMORY, SHOWN_REASONING] diff --git a/tests/adapters/codex/activity/test_custom_tool.py b/tests/adapters/codex/activity/test_custom_tool.py new file mode 100644 index 000000000..851ef2941 --- /dev/null +++ b/tests/adapters/codex/activity/test_custom_tool.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pydantic import BaseModel + +from band.testing.activity import ActivityRecorder +from tests.adapters.codex.kit import ToolSchemaFakeTools, run_codex_turn +from tests.adapters.codexturns import final_text, tool_call_request, turn_completed + + +class LookupInput(BaseModel): + """Look up a word.""" + + word: str + + +def lookup(inp: LookupInput) -> str: + return f"{inp.word}: a definition" + + +class TestCodexCustomToolActivity: + async def test_a_custom_tool_shows_its_name_until_it_returns( + self, recorder: ActivityRecorder + ) -> None: + await run_codex_turn( + events=[ + tool_call_request(1, "lookup", {"word": "activity"}), + final_text("Done."), + turn_completed(), + ], + tools=recorder.observing(ToolSchemaFakeTools()), + additional_tools=[(LookupInput, lookup)], + ) + + recorder.assert_completed_tool("lookup") diff --git a/tests/adapters/codex/activity/test_permission.py b/tests/adapters/codex/activity/test_permission.py new file mode 100644 index 000000000..4ce2e289f --- /dev/null +++ b/tests/adapters/codex/activity/test_permission.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from band.adapters.codex import CodexCommand +from band.testing.activity import ActivityRecorder +from tests.adapters.codex.kit import CodexApprovalRoom + + +class TestCodexPermissionActivity: + async def test_the_room_shows_permission_until_its_last_approval_is_answered( + self, + approval_room: Callable[..., Awaitable[CodexApprovalRoom]], + recorder: ActivityRecorder, + ) -> None: + room = await approval_room() + recorder.observing(room.tools) + async with room.prompted(count=2): + first = room.ask("first", request_id=1) + second = room.ask("second", request_id=2) + assert recorder.waiting + + first_token, second_token = room.approval_tokens + await room.reply(CodexCommand.APPROVE, first_token) + assert await first == "accept" + assert recorder.waiting + + await room.reply(CodexCommand.APPROVE, second_token) + assert await second == "accept" + assert not recorder.waiting diff --git a/tests/adapters/codex/activity/test_tool_items.py b/tests/adapters/codex/activity/test_tool_items.py new file mode 100644 index 000000000..eae1cc1f9 --- /dev/null +++ b/tests/adapters/codex/activity/test_tool_items.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from band.testing.activity import SHOWN_REASONING, ActivityRecorder, shown_tool +from tests.adapters.codex.activity.kit import ( + BAND_TOOL_CALL, + DOCS_LOOKUP, + LIST_FILES, + item_completed, + item_started, +) +from tests.adapters.codex.kit import ToolSchemaFakeTools, run_codex_turn +from tests.adapters.codexturns import final_text, turn_completed + + +class TestCodexToolItemActivity: + async def test_each_tool_item_shows_its_name_until_it_completes( + self, recorder: ActivityRecorder + ) -> None: + """A Band tool Codex asks the adapter to run reports where it runs, + so its dynamicToolCall item shows nothing.""" + await run_codex_turn( + events=[ + item_started(LIST_FILES), + item_completed(LIST_FILES), + item_started(BAND_TOOL_CALL), + item_completed(BAND_TOOL_CALL), + item_started(DOCS_LOOKUP), + item_completed(DOCS_LOOKUP), + final_text("Done."), + turn_completed(), + ], + tools=recorder.observing(ToolSchemaFakeTools()), + ) + + assert recorder.events == [ + shown_tool("exec"), + SHOWN_REASONING, + shown_tool("mcp:docs/search"), + SHOWN_REASONING, + ] From 903832a4bf08db8fcffacc19dcda5704d8ef41e2 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 20:57:58 +0300 Subject: [PATCH 3/4] feat: Report MCP-served custom tools as activity A room-bound custom tool on the embedded MCP engine shows its name on its room's turn while it runs, then reasoning. This covers every adapter that serves custom tools through the engine (Claude SDK, OpenCode, Letta, ACP client). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- src/band/integrations/mcp/engine.py | 17 ++++---- tests/mcp/test_custom_tool_activity.py | 57 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 tests/mcp/test_custom_tool_activity.py diff --git a/src/band/integrations/mcp/engine.py b/src/band/integrations/mcp/engine.py index 2bd34d752..7e8234d6f 100644 --- a/src/band/integrations/mcp/engine.py +++ b/src/band/integrations/mcp/engine.py @@ -42,7 +42,6 @@ from band.core.exceptions import BandToolError, MessagePostError, MessagePostKind from band.core.protocols import AgentToolsProtocol from band.core.tool_filter import sanitize_tool_schema -from band.core.turn import Turn from band.core.types import Capability, EventMessageType, MessageType from band.runtime.custom_tools import ( CustomToolDef, @@ -572,12 +571,13 @@ async def execute(arguments: dict[str, Any]) -> Any: RoomToolResolver = Callable[[str], AgentToolsProtocol | None] -def room_turn(get_tools: RoomToolResolver | None, chat_id: str | None) -> Turn | None: - """The turn of the room ``chat_id`` names, if the tool is room-bound.""" +def room_tools( + get_tools: RoomToolResolver | None, chat_id: str | None +) -> AgentToolsProtocol | None: + """The tools of the room ``chat_id`` names, if the tool is room-bound.""" if get_tools is None or chat_id is None: return None - tools = get_tools(chat_id) - return tools.turn if tools is not None else None + return get_tools(chat_id) def build_custom_tool_registration( @@ -609,8 +609,11 @@ def build_custom_tool_registration( async def execute(arguments: dict[str, Any]) -> Any: kwargs = dict(arguments) chat_id = kwargs.pop(CHAT_ID_FIELD_NAME, None) - turn = room_turn(get_tools, chat_id) - result = await execute_custom_tool(tool_def, kwargs, turn=turn) + tools = room_tools(get_tools, chat_id) + async with tool_activity(tools, tool_name): + result = await execute_custom_tool( + tool_def, kwargs, turn=tools.turn if tools is not None else None + ) return tool_result_text(result) return MCPToolRegistration( diff --git a/tests/mcp/test_custom_tool_activity.py b/tests/mcp/test_custom_tool_activity.py new file mode 100644 index 000000000..66f078238 --- /dev/null +++ b/tests/mcp/test_custom_tool_activity.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import asyncio + +from mcp.shared.memory import create_connected_server_and_client_session +from pydantic import BaseModel + +from band.integrations.mcp.engine import ( + CustomToolSpec, + EngineSpec, + build_custom_tool_registration, + build_engine, +) +from band.testing.activity import ActivityRecorder, shown_tool +from band.testing.fake_tools import FakeAgentTools + + +class LookupInput(BaseModel): + """Look up a word.""" + + word: str + + +class TestCustomToolActivity: + async def test_a_room_bound_custom_tool_shows_on_its_room_until_it_returns( + self, + ) -> None: + entered, release = asyncio.Event(), asyncio.Event() + + async def lookup(inp: LookupInput) -> str: + entered.set() + await release.wait() + return f"{inp.word}: a definition" + + asked, other = ActivityRecorder(), ActivityRecorder() + rooms = { + "room-1": other.observing(FakeAgentTools(room_id="room-1")), + "room-2": asked.observing(FakeAgentTools(room_id="room-2")), + } + registration = build_custom_tool_registration( + CustomToolSpec(input_model=LookupInput, handler=lookup), + get_tools=rooms.get, + ) + server = build_engine(EngineSpec(name="lookup", tools=(registration,))) + + async with create_connected_server_and_client_session(server) as session: + call = asyncio.create_task( + session.call_tool("lookup", {"word": "activity", "chat_id": "room-2"}) + ) + await entered.wait() + assert asked.events == [shown_tool("lookup")] + release.set() + result = await call + + assert not result.isError, result.content + asked.assert_completed_tool("lookup") + assert other.events == [] From 164ee9e10b3483fdb6bae0a90f4fdca95a367048 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 20:57:58 +0300 Subject: [PATCH 4/4] test: Prove Claude SDK reports native tool activity with narration off With emit=() a native Bash call still shows tool_call Bash on the turn, and the room gets no tool_call or tool_result events. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- .../claude_sdk/activity/test_narration_off.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/adapters/claude_sdk/activity/test_narration_off.py diff --git a/tests/adapters/claude_sdk/activity/test_narration_off.py b/tests/adapters/claude_sdk/activity/test_narration_off.py new file mode 100644 index 000000000..8c443ca8d --- /dev/null +++ b/tests/adapters/claude_sdk/activity/test_narration_off.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from band.core.types import MessageType +from band.testing.activity import ActivityRecorder +from tests.adapters.claude_sdk.activity.kit import ( + REPLY_THEN_BASH_ACTIVITY, + reply_then_bash, +) +from tests.adapters.claude_sdk.helpers import OpenRoom + +NARRATION = {MessageType.TOOL_CALL, MessageType.TOOL_RESULT} + + +class TestActivityWithoutNarration: + async def test_a_native_tool_reports_activity_while_narration_is_off( + self, claude_room: OpenRoom, recorder: ActivityRecorder + ) -> None: + room = await claude_room(emit=()) + recorder.observing(room.tools) + room.claude.script(reply_then_bash(room)) + + await room.send("List the files") + + assert recorder.events == REPLY_THEN_BASH_ACTIVITY + assert NARRATION.isdisjoint(room.events)