Skip to content

Commit beb339b

Browse files
committed
feat(omp): report native tool and permission activity
The native adapter reports each host tool's start and end through the shared activity helpers while tool-call narration is enabled, and marks a permission wait around the resolver whatever the narration choice, so the room's working indicator reflects native work and dialogs.
1 parent 9a34406 commit beb339b

4 files changed

Lines changed: 228 additions & 7 deletions

File tree

‎src/band/adapters/omp.py‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
OmpUIRequest,
7575
OmpUIResponse,
7676
)
77+
from band.runtime.activity import set_permission_wait
7778
from band.runtime.custom_tools import (
7879
CustomToolDef,
7980
custom_tools_to_schemas,
@@ -86,6 +87,7 @@
8687
TURN_COMPLETION_GUIDANCE,
8788
render_system_prompt,
8889
)
90+
from band.runtime.toolactivity import end_tool_activity, report_tool_activity
8991
from band.runtime.tools import (
9092
BAND_MCP_SERVER_NAME,
9193
get_band_tool_category,
@@ -869,14 +871,18 @@ async def _permission(
869871
options = tuple(request.options or ())
870872
choice = None
871873
if self._resolve_permission is not None:
872-
choice = await self._resolve_permission(
873-
OmpPermissionRequest(
874-
room_id=room_id,
875-
session_id=session_id,
876-
title=request.title or "",
877-
options=options,
874+
await set_permission_wait(tools, True)
875+
try:
876+
choice = await self._resolve_permission(
877+
OmpPermissionRequest(
878+
room_id=room_id,
879+
session_id=session_id,
880+
title=request.title or "",
881+
options=options,
882+
)
878883
)
879-
)
884+
finally:
885+
await set_permission_wait(tools, False)
880886
elif options == ("Approve", "Deny"):
881887
choice = "Approve"
882888
response = (
@@ -950,6 +956,7 @@ async def _emit_event(self, event: OmpEvent, turn: OmpTurn) -> None:
950956
)
951957
turn.calls[event.toolCallId] = (name, arguments)
952958
if Emit.TOOL_CALLS in self.features.emit:
959+
await report_tool_activity(turn.tools, name)
953960
await send_event_safe(
954961
turn.tools,
955962
self._encode_tool_call(name, arguments, event.toolCallId),
@@ -960,6 +967,8 @@ async def _emit_event(self, event: OmpEvent, turn: OmpTurn) -> None:
960967
if call is None:
961968
return
962969
name, _arguments = call
970+
if Emit.TOOL_CALLS in self.features.emit:
971+
await end_tool_activity(turn.tools)
963972
if not self.config.inject_band_tools and not event.isError:
964973
turn.tools.turn.record(turn_effect(name))
965974
if Emit.TOOL_CALLS in self.features.emit:

‎tests/adapters/omp/kit.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from band.adapters.omp import OmpAdapter, OmpAdapterConfig
1616
from band.converters.omp import OmpSessionState
17+
from band.core.types import Emit
1718
from band.integrations.ompprocess import OmpMcpServer
1819
from band.integrations.omprpc import OmpRpcClient
1920
from band.integrations.ompwire import (
@@ -544,3 +545,38 @@ async def ask_turn(
544545
async def ask_frames(adapter: OmpAdapter) -> list[dict[str, JsonValue]]:
545546
checkpoint = await adapter._rooms["room-123"].runtime.request("checkpoint")
546547
return cast(list[dict[str, JsonValue]], checkpoint["frames"])
548+
549+
550+
EMIT_ON: tuple[Emit, ...] = (Emit.TOOL_CALLS,)
551+
EMIT_OFF: tuple[Emit, ...] = ()
552+
553+
554+
def make_adapter(
555+
tmp_path: Path,
556+
emit: tuple[Emit, ...],
557+
resolve_permission=None, # type: ignore[no-untyped-def]
558+
) -> OmpAdapter:
559+
return OmpAdapter(
560+
OmpAdapterConfig(cwd=str(tmp_path)),
561+
emit=emit,
562+
resolve_permission=resolve_permission,
563+
)
564+
565+
566+
def tool_start(call_id: str, name: str, **args: object) -> OmpEvent:
567+
return OmpEvent(
568+
type="tool_execution_start",
569+
toolCallId=call_id,
570+
toolName=name,
571+
args=args,
572+
)
573+
574+
575+
def tool_end(call_id: str, name: str, *, is_error: bool = False) -> OmpEvent:
576+
return OmpEvent(
577+
type="tool_execution_end",
578+
toolCallId=call_id,
579+
toolName=name,
580+
result=OmpToolResult(content=[OmpContent(type="text", text="private-result")]),
581+
isError=is_error,
582+
)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Native OMP permission waits are reported as activity whatever the narration choice."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
from band.adapters.omp import OmpAdapter, OmpAdapterConfig, OmpPermissionRequest
11+
from band.core.types import Emit
12+
from band.integrations.ompwire import OmpUIRequest
13+
from band.testing.activity import ActivityRecorder
14+
from band.testing.fake_tools import FakeAgentTools
15+
from tests.adapters.omp.kit import EMIT_OFF, EMIT_ON, make_adapter
16+
17+
18+
def waiting(recorder: ActivityRecorder) -> bool:
19+
"""Whether the last manual-wait observation left the turn waiting."""
20+
waits = [event["waiting"] for event in recorder.events if "waiting" in event]
21+
return bool(waits) and waits[-1]
22+
23+
24+
class TestOmpPermissionActivity:
25+
@pytest.mark.parametrize(
26+
"emit", [EMIT_ON, EMIT_OFF], ids=["narration-on", "narration-off"]
27+
)
28+
async def test_wait_is_reported_whatever_the_narration_choice(
29+
self, tmp_path: Path, emit: tuple[Emit, ...]
30+
) -> None:
31+
recorder = ActivityRecorder()
32+
tools = recorder.observing(FakeAgentTools())
33+
release = asyncio.Event()
34+
waiting_during: list[bool] = []
35+
36+
async def resolve(request: OmpPermissionRequest) -> str | None:
37+
waiting_during.append(waiting(recorder))
38+
await release.wait()
39+
return "Approve" if request.options == ("Approve", "Deny") else None
40+
41+
adapter = OmpAdapter(
42+
OmpAdapterConfig(cwd=str(tmp_path)),
43+
emit=emit,
44+
resolve_permission=resolve,
45+
)
46+
request = OmpUIRequest(
47+
id="ui-1",
48+
method="select",
49+
title='Allow tool: Bash\nArguments: {"command": "private-command"}',
50+
options=["Approve", "Deny"],
51+
)
52+
running = asyncio.create_task(
53+
adapter._permission(request, "room", "sess", tools)
54+
)
55+
while not waiting_during:
56+
await asyncio.sleep(0)
57+
assert waiting(recorder)
58+
release.set()
59+
response = await running
60+
61+
assert response.value == "Approve"
62+
assert waiting_during == [True]
63+
assert not waiting(recorder)
64+
assert recorder.events == [{"waiting": True}, {"waiting": False}]
65+
assert not tools.events_sent
66+
67+
async def test_auto_approved_selection_reports_no_wait(
68+
self, tmp_path: Path
69+
) -> None:
70+
adapter = make_adapter(tmp_path, EMIT_OFF)
71+
recorder = ActivityRecorder()
72+
tools = recorder.observing(FakeAgentTools())
73+
request = OmpUIRequest(
74+
id="ui-1",
75+
method="select",
76+
title="Allow tool: Bash",
77+
options=["Approve", "Deny"],
78+
)
79+
80+
response = await adapter._permission(request, "room", "sess", tools)
81+
82+
assert response.value == "Approve"
83+
assert recorder.events == []
84+
85+
async def test_non_select_ui_requests_report_no_wait(self, tmp_path: Path) -> None:
86+
adapter = make_adapter(tmp_path, EMIT_OFF)
87+
recorder = ActivityRecorder()
88+
tools = recorder.observing(FakeAgentTools())
89+
90+
response = await adapter._permission(
91+
OmpUIRequest(id="ui-1", method="confirm", title="private-question"),
92+
"room",
93+
"sess",
94+
tools,
95+
)
96+
97+
assert response.cancelled
98+
assert recorder.events == []
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Native OMP tool activity is reported independently of narration."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from band.adapters.omp import OmpTurn
10+
from band.core.types import Emit
11+
from band.testing.activity import ActivityRecorder
12+
from band.testing.fake_tools import FakeAgentTools
13+
from tests.adapters.omp.kit import EMIT_OFF, EMIT_ON, make_adapter, tool_end, tool_start
14+
15+
16+
class TestOmpToolActivity:
17+
@pytest.mark.parametrize(
18+
"emit", [EMIT_ON, EMIT_OFF], ids=["narration-on", "narration-off"]
19+
)
20+
async def test_tool_activity_follows_the_narration_switch(
21+
self, tmp_path: Path, emit: tuple[Emit, ...]
22+
) -> None:
23+
adapter = make_adapter(tmp_path, emit)
24+
recorder = ActivityRecorder()
25+
tools = recorder.observing(FakeAgentTools())
26+
turn = OmpTurn(tools=tools)
27+
28+
await adapter._emit_event(
29+
tool_start("call-1", "Bash", command="private-command"), turn
30+
)
31+
await adapter._emit_event(tool_end("call-1", "Bash"), turn)
32+
33+
if emit:
34+
recorder.assert_completed_tool("Bash")
35+
else:
36+
assert recorder.events == []
37+
assert bool(tools.events_sent) is bool(emit)
38+
39+
async def test_only_the_name_reaches_the_recorder(self, tmp_path: Path) -> None:
40+
adapter = make_adapter(tmp_path, EMIT_ON)
41+
recorder = ActivityRecorder()
42+
tools = recorder.observing(FakeAgentTools())
43+
turn = OmpTurn(tools=tools)
44+
45+
await adapter._emit_event(
46+
tool_start(
47+
"call-1", "Write", file_path="private-path", content="private-content"
48+
),
49+
turn,
50+
)
51+
assert recorder.events == [{"kind": "tool_call", "name": "Write"}]
52+
53+
async def test_latest_open_tool_wins(self, tmp_path: Path) -> None:
54+
adapter = make_adapter(tmp_path, EMIT_ON)
55+
recorder = ActivityRecorder()
56+
tools = recorder.observing(FakeAgentTools())
57+
turn = OmpTurn(tools=tools)
58+
59+
await adapter._emit_event(tool_start("call-a", "Read"), turn)
60+
await adapter._emit_event(tool_start("call-b", "Bash"), turn)
61+
assert recorder.events == [
62+
{"kind": "tool_call", "name": "Read"},
63+
{"kind": "tool_call", "name": "Bash"},
64+
]
65+
66+
async def test_end_of_untracked_call_changes_nothing(self, tmp_path: Path) -> None:
67+
adapter = make_adapter(tmp_path, EMIT_ON)
68+
recorder = ActivityRecorder()
69+
tools = recorder.observing(FakeAgentTools())
70+
turn = OmpTurn(tools=tools)
71+
72+
await adapter._emit_event(tool_end("never-started", "Ghost"), turn)
73+
assert recorder.events == []
74+
75+
await adapter._emit_event(tool_start("call-1", "Bash"), turn)
76+
await adapter._emit_event(tool_end("call-1", "Bash"), turn)
77+
await adapter._emit_event(tool_end("call-1", "Bash"), turn)
78+
recorder.assert_completed_tool("Bash")

0 commit comments

Comments
 (0)