From d1419b7db3c4388a746e577e9792921ae5e7d2d0 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 18 Oct 2026 10:08:37 +0300 Subject: [PATCH 01/33] fix: Keep working-state reporting alive through provider turns --- docs/turn-outcome.md | 22 +- docs/websocket-events.md | 9 +- src/band/adapters/opencode/adapter.py | 53 ++-- src/band/runtime/execution.py | 125 ++++++++-- src/band/runtime/tools/agent.py | 4 +- src/band/runtime/working_state.py | 1 + tests/runtime/activityownership/__init__.py | 1 + tests/runtime/activityownership/conftest.py | 56 +++++ tests/runtime/activityownership/kit.py | 17 ++ .../activityownership/test_lifetime.py | 198 +++++++++++++++ .../activityownership/test_opencode.py | 77 ++++++ .../runtime/executionworkingstate/__init__.py | 1 + .../runtime/executionworkingstate/conftest.py | 38 +++ tests/runtime/executionworkingstate/kit.py | 52 ++++ .../test_sessionconfigguards.py | 56 +++++ .../executionworkingstate/test_wiring.py | 104 ++++++++ tests/runtime/test_execution_working_state.py | 233 ------------------ tests/runtime/test_working_state.py | 203 --------------- tests/runtime/workingstate/__init__.py | 1 + tests/runtime/workingstate/kit.py | 28 +++ .../runtime/workingstate/test_cancellation.py | 47 ++++ .../workingstate/test_failureswallowed.py | 22 ++ tests/runtime/workingstate/test_keepalive.py | 32 +++ .../runtime/workingstate/test_maxduration.py | 29 +++ tests/runtime/workingstate/test_ordering.py | 25 ++ tests/runtime/workingstate/test_startstop.py | 52 ++++ tests/runtime/workingstate/test_validation.py | 12 + 27 files changed, 1019 insertions(+), 479 deletions(-) create mode 100644 tests/runtime/activityownership/__init__.py create mode 100644 tests/runtime/activityownership/conftest.py create mode 100644 tests/runtime/activityownership/kit.py create mode 100644 tests/runtime/activityownership/test_lifetime.py create mode 100644 tests/runtime/activityownership/test_opencode.py create mode 100644 tests/runtime/executionworkingstate/__init__.py create mode 100644 tests/runtime/executionworkingstate/conftest.py create mode 100644 tests/runtime/executionworkingstate/kit.py create mode 100644 tests/runtime/executionworkingstate/test_sessionconfigguards.py create mode 100644 tests/runtime/executionworkingstate/test_wiring.py delete mode 100644 tests/runtime/test_execution_working_state.py delete mode 100644 tests/runtime/test_working_state.py create mode 100644 tests/runtime/workingstate/__init__.py create mode 100644 tests/runtime/workingstate/kit.py create mode 100644 tests/runtime/workingstate/test_cancellation.py create mode 100644 tests/runtime/workingstate/test_failureswallowed.py create mode 100644 tests/runtime/workingstate/test_keepalive.py create mode 100644 tests/runtime/workingstate/test_maxduration.py create mode 100644 tests/runtime/workingstate/test_ordering.py create mode 100644 tests/runtime/workingstate/test_startstop.py create mode 100644 tests/runtime/workingstate/test_validation.py diff --git a/docs/turn-outcome.md b/docs/turn-outcome.md index 7402a79a1..6951fad99 100644 --- a/docs/turn-outcome.md +++ b/docs/turn-outcome.md @@ -374,7 +374,7 @@ refusal instead of returning `DONE`; a drain records only accepted messages. ## Cancellation ownership -Each runtime handler and SDK-managed Codex/Claude worker has one task owner. +Each runtime handler and SDK-managed Claude, Codex or OpenCode worker has one task owner. The originating turn scope coordinates termination and fixes one absolute cleanup deadline using the existing one-second grace. Repeated controls still notify the adapter and update the unsettled delivery outcome, but never cancel its cleanup @@ -384,9 +384,9 @@ A stopped tool or auxiliary claim first revokes that scope's posting permission and raises cancellation in its caller. Bound workers and the handler get the same cleanup grace to unwind cooperatively; an unfinished task receives cancellation once at expiry if none was delivered, even when nothing waits for it. Native -controls, heartbeat refusal, watchdog expiry and shutdown cancel a running turn -immediately; a worker already released for approval is left to the adapter's -room cleanup. Provider execution and recovery timeouts retain their adapter +STOP and INTERRUPT controls, heartbeat refusal and watchdog expiry cancel the +originating turn, including a worker released for approval. Leave and shutdown +clear reporting while released worker cleanup remains with adapter residency. Provider execution and recovery timeouts retain their adapter policy and coordinate with that same owner. Unfinished tasks remain owned after grace expires. They cannot post late replies, @@ -396,3 +396,17 @@ a later existing host cleanup retry completes retirement. Worker completion alon does not readmit the room. Successful approval release retains its original tools and judgment until the worker actually finishes; `Turn.detach()` is still only an outcome-ledger operation. + +## Working-state lifetime + +The activity owner is the originating turn scope, independently of the current +message delivery. A registered provider worker keeps its reporter alive after +the handler settles, including while waiting for human approval. Later approval +replies and busy deliveries do not replace that owner or restart its duration +cap. Reporting ends when both the handler and its workers finish. + +An echoed heartbeat STOP can cancel that detached provider and notify its adapter +once. It does not rewrite the original delivery's settled acknowledgement. +Explicit interruption invalidates its activity; leave and shutdown clear the +indicator while adapter residency retains responsibility for released workers. +The authoritative idle report finishes before a replacement reports working. diff --git a/docs/websocket-events.md b/docs/websocket-events.md index f233da3fb..f623f8cd4 100644 --- a/docs/websocket-events.md +++ b/docs/websocket-events.md @@ -49,7 +49,10 @@ While working-state reporting is active, its existing activity keep-alive also recovers a missed STOP: the platform answers a `working: true` report with `working: false` when the room execution is stopped. The runtime cancels the originating turn through the same platform-stop path as a refused claim or post, -leaving its message PROCESSING for later replay. Responses from before PLAY, +leaving an unsettled message PROCESSING for later replay. A provider worker +released for approval retains its originating activity scope across later +deliveries; a late heartbeat STOP cancels that provider without changing the +original message's settled acknowledgement. Responses from before PLAY, a replacement turn, or shutdown cannot stop newer work. Request failures never count as STOP, and the public activity-report boolean still means request success. @@ -94,7 +97,9 @@ never interprets approval syntax or drains model context for a control. Room admission and control revisions belong to the execution context; a turn scope owns termination intent and delivery disposition. Each concrete task has -one cancellation owner, including explicitly bound Codex/Claude workers. +one cancellation owner, including explicitly bound Claude, Codex and OpenCode +workers. Activity follows that scope until its handler and registered workers +finish, independently of current delivery admission. Shutdown takes precedence; otherwise the latest accepted native STOP/INTERRUPT before settlement determines delivery. An observed REST refusal cannot replace an accepted native disposition. PLAY advances admission without reviving an diff --git a/src/band/adapters/opencode/adapter.py b/src/band/adapters/opencode/adapter.py index 8de9c2381..a9f826d1d 100644 --- a/src/band/adapters/opencode/adapter.py +++ b/src/band/adapters/opencode/adapter.py @@ -18,6 +18,7 @@ from band.adapters.opencode.approvals import ApprovalPorts, RoomApprovals from band.adapters.opencode.config import OpencodeAdapterConfig +from band.client.streaming import ControlMode from band.converters.opencode import OpencodeHistoryConverter from band.core.delivery import relay_reply from band.core.exceptions import BandConnectionError, InputNotAccepted @@ -28,6 +29,11 @@ ) from band.core.simple_adapter import SimpleAdapter from band.core.turn import judge_detached_turn +from band.core.turn_lifecycle import ( + CYCLE_CANCEL_GRACE_SECONDS, + TurnTask, + bind_turn_task, +) from band.core.types import ( AdapterFeatures, Capability, @@ -99,6 +105,8 @@ class TurnState: assistant_part_types: dict[str, str] = field(default_factory=dict) reported_tool_calls: set[str] = field(default_factory=set) reported_tool_results: set[str] = field(default_factory=set) + worker: TurnTask | None = None + abort_started: bool = False last_error_message: str | None = None # Per-assistant-message usage for the current turn (last-write-wins per id, # since message.updated streams repeatedly). Summed across messages at turn @@ -532,6 +540,7 @@ async def on_message( turn, ) ) + turn.worker = bind_turn_task(tools, turn_task) # Register the watcher only while this turn is still current; a # superseded turn's task must not clobber (or be cancelled through) # the next turn's ambient pointer. @@ -547,21 +556,12 @@ async def on_message( # Nothing awaited here re-raises asyncio.TimeoutError, so on_message has no # timeout handler of its own. except asyncio.CancelledError: - # The runtime interrupts a turn by cancelling this coroutine, but the - # watcher runs detached. Left alone it outlives the interrupt: it - # posts the very reply the user stopped, and holds the room's busy - # guard until turn_timeout_s. Drop the turn state even if a second - # cancellation (e.g. a double-tap stop) cuts off the abandon/abort - # awaits below -- otherwise the room stays wedged on a turn whose - # state never clears. - if turn_future is not None: - assert turn is not None + if turn is not None: try: - already_aborted = await turn.approvals.abandon() + await turn.approvals.abandon() + await self._abort_turn(turn, "interrupted") finally: self._clear_turn_state(room_state, expected_turn=turn) - if not already_aborted: - await self._abort_turn(turn, "interrupted") raise except httpx.HTTPStatusError as exc: logger.exception("OpenCode request failed for room %s", room_id) @@ -1029,6 +1029,9 @@ async def _watch_turn_completion( ) try: await self._await_turn(turn) + except asyncio.CancelledError: + await self._abort_turn(turn, "interrupted") + raise except TimeoutError: logger.warning( "OpenCode turn timed out for room %s (session=%s)", @@ -1075,10 +1078,25 @@ async def _watch_turn_completion( expected_task=asyncio.current_task(), ) + async def on_interrupt(self, room_id: str, mode: ControlMode) -> None: + state = self._rooms.get(room_id) + if state is None or state.turn is None: + return + turn = state.turn + if turn.worker is not None: + turn.worker.terminate() + await turn.worker.drain() + else: + await self._abort_turn(turn, "interrupted") + async def _abort_turn(self, turn: TurnState, reason: str) -> None: """Best-effort: tell OpenCode to stop working on this room's session.""" + if turn.abort_started: + return + turn.abort_started = True try: - await turn.client.abort_session(turn.session_id) + async with asyncio.timeout(CYCLE_CANCEL_GRACE_SECONDS): + await turn.client.abort_session(turn.session_id) except Exception: logger.exception( "Failed to abort %s OpenCode session %s", @@ -1173,8 +1191,13 @@ def _clear_turn_state( return turn_task = room_state.turn_task - if turn_task is not None and turn_task is not expected_task: - turn_task.cancel() + if ( + turn_task is not None + and turn_task is not expected_task + and room_state.turn is not None + and room_state.turn.worker is not None + ): + room_state.turn.worker.terminate() if room_state.turn is not None: room_state.turn.approvals.cancel() diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index 6948d1133..701f25ded 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -413,6 +413,9 @@ def __init__( self._detached_cycle_tasks: dict[asyncio.Task[None], TurnTask] = {} self.current_scope: TurnScope | None = None + self._activity_owner: TurnScope | None = None + self._activity_stop_scope: TurnScope | None = None + self._activity_completion: asyncio.Task[None] | None = None self._control_revision = 0 self._on_platform_stop = on_platform_stop self._shutdown_task: asyncio.Task[bool] | None = None @@ -451,8 +454,10 @@ def is_stopped(self) -> bool: @property def adapter_stop_notified(self) -> bool: """Whether an observed STOP already notified the adapter for this turn.""" - scope = self.current_scope - return scope is not None and scope.observer_task is not None + return any( + scope is not None and scope.observer_task is not None + for scope in (self.current_scope, self._activity_stop_scope) + ) def _set_state(self, new_state: ExecutionState) -> None: """ @@ -629,6 +634,8 @@ async def _stop(self, timeout: float | None) -> bool: loop_owner.terminate() await self._drain_or_retain(loop_owner) self._process_loop_task = None + self._activity_owner = None + await self._join_activity_completion() await self._stop_retained_tasks(terminal_deadline()) await self._working_reporter.stop() return self._stop_graceful @@ -736,10 +743,13 @@ def _active_cycle_task(self) -> asyncio.Task[None] | None: def _cancel_cycle(self, kind: ControlMode) -> bool: scope = self._running_scope - if scope is None: - return False - scope.terminate(kind) - return True + activity = self._activity_owner + if activity is not None and activity is not scope: + activity.terminate(kind) + self._finish_activity(activity, aborted=True) + if scope is not None: + scope.terminate(kind) + return scope is not None or activity is not None def stop_room(self) -> None: """Pause triggers and abort the cycle, leaving it actionable for PLAY.""" @@ -767,7 +777,11 @@ def _reset_resync_interval(self) -> None: self._queue_wait.reschedule(self._resync_schedule.due_at) def owns_stop_observation(self, scope: TurnScope) -> bool: - return self._owns_control_revision(scope, scope.stop_revision) + return ( + scope + in (self.current_scope, self._activity_owner, self._activity_stop_scope) + and scope.stop_revision == self._control_revision + ) def _owns_control_revision(self, scope: TurnScope, revision: int | None) -> bool: return self.current_scope is scope and revision == self._control_revision @@ -784,8 +798,10 @@ def observe_platform_stop( scope.control_revision if control_revision is None else control_revision ) scope.terminate(ControlMode.STOP, cooperative=cooperative, observed=True) - if self.current_scope is not scope: - return + if scope is self._activity_owner: + self._activity_stop_scope = scope + if scope.settled: + self._finish_activity(scope, aborted=True) if ( not self._closing and self.owns_stop_observation(scope) @@ -1842,14 +1858,19 @@ async def _report_working_state(self, working: bool) -> bool: working, timeout_seconds=self.config.working_request_timeout_seconds, on_stopped=partial( - self._observe_activity_stop, self.current_scope, self._control_revision + self._observe_activity_stop, + self._activity_owner or self.current_scope, + self._control_revision, ), ) def _observe_activity_stop(self, scope: TurnScope | None, revision: int) -> None: if ( scope is None - or not self._owns_control_revision(scope, revision) + or not ( + scope in (self._activity_owner, self.current_scope) + and revision == self._control_revision + ) or scope.stop_observed or self._closing or scope.kind is not None @@ -1863,8 +1884,9 @@ def _observe_activity_stop(self, scope: TurnScope | None, revision: int) -> None async def _execute_message_cycle( self, event: PlatformEvent, message_id: str | None ) -> None: - await self._working_reporter.start() scope = self.current_scope + if scope is not None: + await self._acquire_activity(scope) if scope is not None and scope.terminating: raise asyncio.CancelledError self._resync_handled_work = True @@ -1872,6 +1894,73 @@ async def _execute_message_cycle( with _observe_phase("handler", self.room_id, message_id): await self._on_execute(self, event) + async def _join_activity_completion(self) -> None: + task = self._activity_completion + if task is not None: + await asyncio.shield(task) + if self._activity_completion is task: + self._activity_completion = None + + async def _acquire_activity(self, scope: TurnScope) -> None: + await self._join_activity_completion() + await self._start_activity(scope) + + async def _start_activity(self, scope: TurnScope) -> None: + previous = self._activity_owner + if previous is not None and previous is not scope: + if not previous.aborted and any( + not worker.task.done() for worker in previous.workers + ): + return + self._finish_activity(previous, aborted=True) + await self._join_activity_completion() + if scope.aborted or self._closing: + return + if self._activity_owner is not scope: + self._activity_stop_scope = None + self._activity_owner = scope + await self._working_reporter.start() + + def register_turn_task(self, scope: TurnScope, owner: TurnTask) -> None: + """Bind completion to the same owner that delivers worker cancellation.""" + scope.bind(owner) + owner.task.add_done_callback(lambda _: self._finish_activity(scope)) + if scope is self.current_scope and self._activity_owner is None: + previous = self._activity_completion + + async def acquire() -> None: + if previous is not None: + await asyncio.shield(previous) + if scope is self.current_scope: + await self._start_activity(scope) + + self._retain_activity_completion(asyncio.create_task(acquire())) + + def _retain_activity_completion(self, task: asyncio.Task[None]) -> None: + self._activity_completion = task + task.add_done_callback(self._activity_completed) + + def _activity_completed(self, task: asyncio.Task[None]) -> None: + task_failure(task) + if self._activity_completion is task: + self._activity_completion = None + if self._activity_owner is not None: + self._finish_activity(self._activity_owner) + + def _finish_activity(self, scope: TurnScope, *, aborted: bool = False) -> None: + if self._activity_owner is not scope: + return + if not aborted and ( + not scope.settled or any(not owner.task.done() for owner in scope.workers) + ): + return + self._activity_owner = None + self._working_reporter.defer_false_report() + if self._activity_completion is None: + self._retain_activity_completion( + asyncio.create_task(self._working_reporter.stop()) + ) + def _retain_detached_cycle_task( self, task: asyncio.Task[None], *, owner: TurnTask | None = None ) -> None: @@ -1957,15 +2046,11 @@ async def invoke() -> None: finally: wake.cancel() await asyncio.gather(wake, return_exceptions=True) - if isinstance(event, MessageEvent) and handler is None: - # stop() joins this cycle inside the settlement budget, which is - # shorter than the activity POST. A closing execution finishes - # working:false after that join. - if self._closing: - self._working_reporter.defer_false_report() - else: - await self._working_reporter.stop() scope.settled = True + if isinstance(event, MessageEvent) and handler is None: + self._finish_activity(scope, aborted=scope.terminating) + if not self._closing: + await self._join_activity_completion() if scope.shutdown: raise asyncio.CancelledError if scope.kind is not None: diff --git a/src/band/runtime/tools/agent.py b/src/band/runtime/tools/agent.py index deeaa144e..6951d5492 100644 --- a/src/band/runtime/tools/agent.py +++ b/src/band/runtime/tools/agent.py @@ -388,8 +388,8 @@ def from_context( return tools def register_turn_task(self, owner: TurnTask) -> None: - if self._scope is not None: - self._scope.bind(owner) + if self._scope is not None and self._ctx is not None: + self._ctx.register_turn_task(self._scope, owner) def _abort_stopped_post(self, error: RoomExecutionStoppedError) -> None: if self._ctx is not None and self._scope is not None: diff --git a/src/band/runtime/working_state.py b/src/band/runtime/working_state.py index f9402c389..4d842c7fe 100644 --- a/src/band/runtime/working_state.py +++ b/src/band/runtime/working_state.py @@ -74,6 +74,7 @@ async def start(self) -> None: # Re-entrant start while active is a no-op: the signal is already # alive and the keep-alive task is already running. return + await self._finish_false_send() self._active = True self._started_at = asyncio.get_running_loop().time() await self._safe_report(True) diff --git a/tests/runtime/activityownership/__init__.py b/tests/runtime/activityownership/__init__.py new file mode 100644 index 000000000..9d48db4f9 --- /dev/null +++ b/tests/runtime/activityownership/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/runtime/activityownership/conftest.py b/tests/runtime/activityownership/conftest.py new file mode 100644 index 000000000..916e0ae7b --- /dev/null +++ b/tests/runtime/activityownership/conftest.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable +from unittest.mock import AsyncMock + +import pytest + +from band.platform.event import PlatformEvent +from band.platform.link import BandLink +from band.runtime.execution import ExecutionContext +from band.runtime.runtime import AgentRuntime +from band.runtime.sessionconfig import SessionConfig +from tests.runtime.activityownership.kit import ActivityPlatform +from tests.runtime.helpers import ( + AGENT_ID, + ROOM_ID, + LifecyclePlatform, + rest_client_over, +) + + +@pytest.fixture +async def peer() -> AsyncIterator[LifecyclePlatform]: + yield ActivityPlatform() + + +@pytest.fixture +async def execution( + peer: LifecyclePlatform, +) -> AsyncIterator[Callable[..., ExecutionContext]]: + contexts: list[ExecutionContext] = [] + async with rest_client_over(peer.answer) as rest: + + def create( + handler: Callable[[ExecutionContext, PlatformEvent], Awaitable[None]], + **settings: object, + ) -> ExecutionContext: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + runtime = AgentRuntime( + link=link, agent_id=AGENT_ID, on_execute=handler, on_control=AsyncMock() + ) + ctx = ExecutionContext( + ROOM_ID, + link, + handler, + agent_id=AGENT_ID, + on_platform_stop=runtime._on_platform_stop, + config=SessionConfig(enable_context_hydration=False, **settings), + ) + contexts.append(ctx) + return ctx + + yield create + for ctx in contexts: + await ctx.stop() diff --git a/tests/runtime/activityownership/kit.py b/tests/runtime/activityownership/kit.py new file mode 100644 index 000000000..d43a5b921 --- /dev/null +++ b/tests/runtime/activityownership/kit.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import httpx + +from tests.runtime.helpers import ALICE, LifecyclePlatform, page + + +class ActivityPlatform(LifecyclePlatform): + def answer(self, request: httpx.Request) -> httpx.Response: + tail = request.url.path.rsplit("/", 1)[-1] + if tail == "participants": + return page([{**ALICE, "role": "member", "status": "active"}]) + if request.method == "POST" and tail == "messages": + return httpx.Response( + 201, json={"data": {"id": "m-1", "success": True, "recipients": []}} + ) + return super().answer(request) diff --git a/tests/runtime/activityownership/test_lifetime.py b/tests/runtime/activityownership/test_lifetime.py new file mode 100644 index 000000000..1dcf0f921 --- /dev/null +++ b/tests/runtime/activityownership/test_lifetime.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +import pytest + +from band.client.streaming import ControlMode +from band.core.turn_lifecycle import TurnTask, bind_turn_task +from band.platform.event import PlatformEvent +from band.runtime.execution import ExecutionContext +from band.runtime.tools.agent import AgentTools +from tests.conftest import make_message_event +from tests.runtime.helpers import IDLE, KEEP_ALIVE, LifecyclePlatform + + +class TestProviderLifetime: + @pytest.mark.looptime + async def test_detached_worker_keeps_heartbeat_across_busy_delivery( + self, execution: Callable[..., ExecutionContext], peer: LifecyclePlatform + ) -> None: + release = asyncio.Event() + workers: list[TurnTask] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + if workers: + return + tools = AgentTools.from_context(ctx) + workers.append(bind_turn_task(tools, asyncio.create_task(release.wait()))) + + ctx = execution(handler) + await ctx._process_event(make_message_event(msg_id="original")) + await asyncio.sleep(3.1) + await ctx._process_event(make_message_event(msg_id="busy")) + assert peer.activity_reports == [KEEP_ALIVE, KEEP_ALIVE] + assert peer.marked("processed") == ["original", "busy"] + release.set() + await workers[0].task + await asyncio.sleep(0) + await ctx._join_activity_completion() + assert peer.activity_reports[-1] == IDLE + + async def test_worker_completion_does_not_clear_running_handler( + self, execution: Callable[..., ExecutionContext], peer: LifecyclePlatform + ) -> None: + finished, release = asyncio.Event(), asyncio.Event() + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + worker = asyncio.create_task(asyncio.sleep(0)) + bind_turn_task(AgentTools.from_context(ctx), worker) + await worker + finished.set() + await release.wait() + + ctx = execution(handler) + turn = asyncio.create_task(ctx._process_event(make_message_event())) + await finished.wait() + assert peer.activity_reports == [KEEP_ALIVE] + release.set() + await turn + assert peer.activity_reports == [KEEP_ALIVE, IDLE] + + @pytest.mark.parametrize("mode", [ControlMode.STOP, ControlMode.INTERRUPT]) + async def test_detached_control_cancels_worker_preserving_settled_ack( + self, + execution: Callable[..., ExecutionContext], + peer: LifecyclePlatform, + mode: ControlMode, + ) -> None: + workers: list[TurnTask] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + workers.append( + bind_turn_task( + AgentTools.from_context(ctx), + asyncio.create_task(asyncio.Event().wait()), + ) + ) + + ctx = execution(handler) + await ctx._process_event(make_message_event(msg_id="original")) + assert ctx.interrupt(kind=mode) + await asyncio.gather(workers[0].task, return_exceptions=True) + await ctx._join_activity_completion() + assert workers[0].task.cancelled() + assert peer.marked("processed") == ["original"] + assert peer.activity_reports == [KEEP_ALIVE, IDLE] + + @pytest.mark.looptime + async def test_detached_heartbeat_stop_preserves_ack( + self, execution: Callable[..., ExecutionContext], peer: LifecyclePlatform + ) -> None: + workers: list[TurnTask] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + if not workers: + workers.append( + bind_turn_task( + AgentTools.from_context(ctx), + asyncio.create_task(asyncio.Event().wait()), + ) + ) + + ctx = execution(handler) + await ctx._process_event(make_message_event(msg_id="original")) + await ctx._process_event(make_message_event(msg_id="busy")) + peer.stopped = True + await asyncio.sleep(3.1) + assert workers[0].task.cancelled() + assert peer.marked("processed") == ["original", "busy"] + assert peer.activity_reports[-1] == IDLE + + @pytest.mark.looptime + async def test_busy_delivery_keeps_original_cap_deadline( + self, execution: Callable[..., ExecutionContext], peer: LifecyclePlatform + ) -> None: + release = asyncio.Event() + workers: list[TurnTask] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + if not workers: + workers.append( + bind_turn_task( + AgentTools.from_context(ctx), + asyncio.create_task(release.wait()), + ) + ) + + ctx = execution(handler, max_working_state_seconds=4.0) + await ctx._process_event(make_message_event(msg_id="original")) + await asyncio.sleep(3.1) + await ctx._process_event(make_message_event(msg_id="busy")) + await asyncio.sleep(6) + assert peer.activity_reports == [KEEP_ALIVE, KEEP_ALIVE] + assert not workers[0].task.done() + release.set() + await workers[0].task + await asyncio.sleep(0) + await ctx._join_activity_completion() + assert peer.activity_reports[-1] == IDLE + + async def test_shutdown_clears_reporting_without_cancelling_detached_worker( + self, execution: Callable[..., ExecutionContext], peer: LifecyclePlatform + ) -> None: + release = asyncio.Event() + workers: list[TurnTask] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + workers.append( + bind_turn_task( + AgentTools.from_context(ctx), asyncio.create_task(release.wait()) + ) + ) + + ctx = execution(handler) + await ctx._process_event(make_message_event()) + await ctx.stop() + assert peer.activity_reports == [KEEP_ALIVE, IDLE] + assert not workers[0].task.done() + release.set() + await workers[0].task + assert peer.activity_reports == [KEEP_ALIVE, IDLE] + + async def test_new_worker_admitted_after_previous_completion_gets_own_indicator( + self, execution: Callable[..., ExecutionContext], peer: LifecyclePlatform + ) -> None: + releases = [asyncio.Event(), asyncio.Event()] + entered, admitted = asyncio.Event(), asyncio.Event() + workers: list[TurnTask] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + index = len(workers) + if index: + entered.set() + await admitted.wait() + workers.append( + bind_turn_task( + AgentTools.from_context(ctx), + asyncio.create_task(releases[index].wait()), + ) + ) + + ctx = execution(handler) + await ctx._process_event(make_message_event(msg_id="original")) + turn = asyncio.create_task(ctx._process_event(make_message_event(msg_id="new"))) + await entered.wait() + releases[0].set() + await workers[0].task + await asyncio.sleep(0) + await ctx._join_activity_completion() + admitted.set() + await turn + assert peer.activity_reports == [KEEP_ALIVE, IDLE, KEEP_ALIVE] + releases[1].set() + await workers[1].task + await asyncio.sleep(0) + await ctx._join_activity_completion() + assert peer.activity_reports == [KEEP_ALIVE, IDLE, KEEP_ALIVE, IDLE] diff --git a/tests/runtime/activityownership/test_opencode.py b/tests/runtime/activityownership/test_opencode.py new file mode 100644 index 000000000..7fb26af66 --- /dev/null +++ b/tests/runtime/activityownership/test_opencode.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from unittest.mock import patch + +import pytest + +from band.adapters.opencode import OpencodeAdapter +from band.client.streaming import ControlMode +from band.integrations.opencode.types import OpencodeSessionState +from band.platform.event import PlatformEvent +from band.runtime.execution import ExecutionContext +from band.runtime.tools.agent import AgentTools +from tests.adapters.opencode.helpers import ( + FakeOpencodeClient, + event_permission, + make_platform_message, +) +from tests.conftest import make_message_event +from tests.mcpbackends import backends_created_by +from tests.runtime.helpers import IDLE, KEEP_ALIVE, ROOM_ID, LifecyclePlatform + + +class TestOpenCodeLifetime: + @pytest.mark.parametrize("mode", [ControlMode.STOP, ControlMode.INTERRUPT]) + async def test_parked_native_turn_dual_dispatch_aborts_remote_once( + self, + execution: Callable[..., ExecutionContext], + peer: LifecyclePlatform, + mode: ControlMode, + ) -> None: + client = FakeOpencodeClient( + prompt_event_sequences=[[event_permission("sess-1", "permission-1")]] + ) + adapter = OpencodeAdapter(client_factory=lambda _: client, emit=()) + aborting, release, cleaned = (asyncio.Event() for _ in range(3)) + original_abort = client.abort_session + + async def abort(session_id: str) -> None: + aborting.set() + await release.wait() + await original_abort(session_id) + cleaned.set() + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + await adapter.on_message( + make_platform_message(room_id=ROOM_ID, sender_id="user-456"), + AgentTools.from_context(ctx), + OpencodeSessionState(), + None, + None, + is_session_bootstrap=True, + room_id=ROOM_ID, + ) + + with backends_created_by(), patch.object(client, "abort_session", abort): + await adapter.on_started("Test", "Test") + ctx = execution(handler) + try: + await ctx._process_event(make_message_event(msg_id="original")) + assert peer.activity_reports == [KEEP_ALIVE] + assert peer.marked("processed") == ["original"] + assert ctx.interrupt(kind=mode) + notify = asyncio.create_task(adapter.on_interrupt(ROOM_ID, mode)) + await aborting.wait() + assert not cleaned.is_set() + release.set() + await notify + await ctx._join_activity_completion() + assert client.aborted_sessions == ["sess-1"] + assert cleaned.is_set() + assert peer.activity_reports == [KEEP_ALIVE, IDLE] + assert peer.marked("processed") == ["original"] + finally: + release.set() + await adapter.on_cleanup(ROOM_ID) diff --git a/tests/runtime/executionworkingstate/__init__.py b/tests/runtime/executionworkingstate/__init__.py new file mode 100644 index 000000000..9d48db4f9 --- /dev/null +++ b/tests/runtime/executionworkingstate/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/runtime/executionworkingstate/conftest.py b/tests/runtime/executionworkingstate/conftest.py new file mode 100644 index 000000000..67bd0bbf7 --- /dev/null +++ b/tests/runtime/executionworkingstate/conftest.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +@pytest.fixture +def mock_link(): + link = MagicMock() + link.agent_id = "agent-123" + link.rest = MagicMock() + + participant1 = MagicMock() + participant1.id = "user-1" + participant1.name = "User One" + participant1.type = "User" + link.rest.agent_api_participants = MagicMock() + link.rest.agent_api_participants.list_agent_chat_participants = AsyncMock( + return_value=MagicMock(data=[participant1]) + ) + link.rest.agent_api_context = MagicMock() + link.rest.agent_api_context.get_agent_chat_context = AsyncMock( + return_value=MagicMock(data=[]) + ) + + link.mark_processing = AsyncMock(return_value=True) + link.mark_processed = AsyncMock(return_value=True) + link.mark_failed = AsyncMock(return_value=True) + link.get_next_message = AsyncMock(return_value=None) + link.get_stale_processing_messages = AsyncMock(return_value=[]) + link.report_activity = AsyncMock(return_value=True) + return link + + +@pytest.fixture +def mock_handler(): + return AsyncMock() diff --git a/tests/runtime/executionworkingstate/kit.py b/tests/runtime/executionworkingstate/kit.py new file mode 100644 index 000000000..0be9f6f5f --- /dev/null +++ b/tests/runtime/executionworkingstate/kit.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +from band.runtime.messages import PlatformMessage +from band.runtime.sessionconfig import SessionConfig + + +def _working_values(link) -> list[bool]: + """Sequence of `working` booleans passed to link.report_activity.""" + return [ + c.args[1] if len(c.args) > 1 else c.kwargs["working"] + for c in link.report_activity.call_args_list + ] + + +_FAST = SessionConfig(working_keep_alive_seconds=4.0) + + +def _backlog_message( + room_id: str = "room-123", msg_id: str = "msg-b1" +) -> PlatformMessage: + return PlatformMessage( + id=msg_id, + room_id=room_id, + content="hi", + sender_id="user-1", + sender_type="User", + sender_name="User One", + message_type="text", + metadata={}, + created_at=datetime.now(UTC), + ) + + +def _independent_link(): + link = MagicMock() + link.agent_id = "agent-123" + link.rest = MagicMock() + p = MagicMock(id="user-1", name="User One", type="User") + link.rest.agent_api_participants.list_agent_chat_participants = AsyncMock( + return_value=MagicMock(data=[p]) + ) + link.rest.agent_api_context.get_agent_chat_context = AsyncMock( + return_value=MagicMock(data=[]) + ) + link.mark_processing = AsyncMock(return_value=True) + link.mark_processed = AsyncMock(return_value=True) + link.mark_failed = AsyncMock(return_value=True) + link.report_activity = AsyncMock(return_value=True) + return link diff --git a/tests/runtime/executionworkingstate/test_sessionconfigguards.py b/tests/runtime/executionworkingstate/test_sessionconfigguards.py new file mode 100644 index 000000000..8431784e0 --- /dev/null +++ b/tests/runtime/executionworkingstate/test_sessionconfigguards.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import pytest + +from band.runtime.sessionconfig import SessionConfig + + +class TestSessionConfigGuards: + def test_defaults_are_valid(self): + cfg = SessionConfig() + assert cfg.enable_working_state is True + assert cfg.working_keep_alive_seconds == 3.0 + assert cfg.working_request_timeout_seconds == 2 + assert cfg.max_working_state_seconds is None + + def test_cadence_must_be_below_ttl_half(self): + with pytest.raises(ValueError): + SessionConfig(working_keep_alive_seconds=5.0) # >= 10/2 + + def test_cadence_must_be_positive(self): + with pytest.raises(ValueError): + SessionConfig(working_keep_alive_seconds=0) + + def test_timeout_must_be_below_cadence(self): + with pytest.raises(ValueError): + SessionConfig( + working_keep_alive_seconds=3.0, working_request_timeout_seconds=3 + ) + + def test_timeout_must_be_positive(self): + with pytest.raises(ValueError): + SessionConfig(working_request_timeout_seconds=0) + + def test_max_duration_if_set_must_be_positive(self): + with pytest.raises(ValueError): + SessionConfig(max_working_state_seconds=0) + + def test_max_cycle_seconds_if_set_must_be_positive(self): + with pytest.raises(ValueError, match="max_cycle_seconds"): + SessionConfig(max_cycle_seconds=0) + with pytest.raises(ValueError, match="max_cycle_seconds"): + SessionConfig(max_cycle_seconds=-1.0) + + def test_disabled_skips_guards(self): + # When disabled, odd values must not raise. + cfg = SessionConfig( + enable_working_state=False, working_keep_alive_seconds=100.0 + ) + assert cfg.enable_working_state is False + + def test_max_cycle_seconds_guard_is_independent_of_working_state(self): + # max_cycle_seconds is a hang-killer unrelated to working-state + # reporting, so its guard must still fire even when working-state + # reporting itself is disabled. + with pytest.raises(ValueError, match="max_cycle_seconds"): + SessionConfig(enable_working_state=False, max_cycle_seconds=0) diff --git a/tests/runtime/executionworkingstate/test_wiring.py b/tests/runtime/executionworkingstate/test_wiring.py new file mode 100644 index 000000000..3ca462dfd --- /dev/null +++ b/tests/runtime/executionworkingstate/test_wiring.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from band.platform.event import ReconnectedEvent +from band.runtime.execution import ExecutionContext +from band.runtime.sessionconfig import SessionConfig +from tests.conftest import ( + make_message_event, + make_participant_added_event, +) +from tests.runtime.executionworkingstate.kit import ( + _FAST, + _backlog_message, + _independent_link, + _working_values, +) + + +class TestWiring: + @pytest.mark.asyncio + async def test_message_event_reports_true_then_false(self, mock_link, mock_handler): + ctx = ExecutionContext("room-123", mock_link, mock_handler, config=_FAST) + + await ctx._process_event(make_message_event(room_id="room-123", msg_id="m1")) + + assert _working_values(mock_link) == [True, False] + for call in mock_link.report_activity.call_args_list: + assert call.args[0] == "room-123" + + @pytest.mark.asyncio + async def test_reports_false_even_when_handler_raises(self, mock_link): + handler = AsyncMock(side_effect=RuntimeError("boom")) + ctx = ExecutionContext("room-123", mock_link, handler, config=_FAST) + + await ctx._process_event(make_message_event(room_id="room-123", msg_id="m1")) + + assert _working_values(mock_link)[-1] is False + assert _working_values(mock_link).count(False) == 1 + + @pytest.mark.asyncio + async def test_participant_event_does_not_report(self, mock_link, mock_handler): + ctx = ExecutionContext("room-123", mock_link, mock_handler, config=_FAST) + + await ctx._process_event( + make_participant_added_event(room_id="room-123", participant_id="user-2") + ) + + mock_link.report_activity.assert_not_called() + + @pytest.mark.asyncio + async def test_reconnected_event_does_not_report(self, mock_link, mock_handler): + ctx = ExecutionContext("room-123", mock_link, mock_handler, config=_FAST) + + await ctx._process_event(ReconnectedEvent(room_id="room-123")) + + mock_link.report_activity.assert_not_called() + + @pytest.mark.asyncio + async def test_hub_room_does_not_report(self, mock_link, mock_handler): + ctx = ExecutionContext( + "hub-1", mock_link, mock_handler, config=_FAST, hub_room_id="hub-1" + ) + + await ctx._process_event(make_message_event(room_id="hub-1", msg_id="m1")) + + mock_link.report_activity.assert_not_called() + + @pytest.mark.asyncio + async def test_backlog_message_reports_true_then_false( + self, mock_link, mock_handler + ): + ctx = ExecutionContext("room-123", mock_link, mock_handler, config=_FAST) + + await ctx._process_backlog_message(_backlog_message()) + + assert _working_values(mock_link) == [True, False] + + @pytest.mark.asyncio + async def test_disabled_config_does_not_report(self, mock_link, mock_handler): + ctx = ExecutionContext( + "room-123", + mock_link, + mock_handler, + config=SessionConfig(enable_working_state=False), + ) + + await ctx._process_event(make_message_event(room_id="room-123", msg_id="m1")) + + mock_link.report_activity.assert_not_called() + + @pytest.mark.asyncio + async def test_rooms_are_isolated(self, mock_handler): + link_a = _independent_link() + link_b = _independent_link() + ctx_a = ExecutionContext("room-a", link_a, mock_handler, config=_FAST) + ExecutionContext("room-b", link_b, mock_handler, config=_FAST) + + await ctx_a._process_event(make_message_event(room_id="room-a", msg_id="m1")) + + link_a.report_activity.assert_called() + link_b.report_activity.assert_not_called() diff --git a/tests/runtime/test_execution_working_state.py b/tests/runtime/test_execution_working_state.py deleted file mode 100644 index fa39effdf..000000000 --- a/tests/runtime/test_execution_working_state.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Integration tests: ExecutionContext wiring of the working-state reporter.""" - -from __future__ import annotations - -from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from band.platform.event import ReconnectedEvent -from band.runtime.execution import ExecutionContext -from band.runtime.messages import PlatformMessage -from band.runtime.sessionconfig import SessionConfig -from tests.conftest import ( - make_message_event, - make_participant_added_event, -) - - -@pytest.fixture -def mock_link(): - link = MagicMock() - link.agent_id = "agent-123" - link.rest = MagicMock() - - participant1 = MagicMock() - participant1.id = "user-1" - participant1.name = "User One" - participant1.type = "User" - link.rest.agent_api_participants = MagicMock() - link.rest.agent_api_participants.list_agent_chat_participants = AsyncMock( - return_value=MagicMock(data=[participant1]) - ) - link.rest.agent_api_context = MagicMock() - link.rest.agent_api_context.get_agent_chat_context = AsyncMock( - return_value=MagicMock(data=[]) - ) - - link.mark_processing = AsyncMock(return_value=True) - link.mark_processed = AsyncMock(return_value=True) - link.mark_failed = AsyncMock(return_value=True) - link.get_next_message = AsyncMock(return_value=None) - link.get_stale_processing_messages = AsyncMock(return_value=[]) - link.report_activity = AsyncMock(return_value=True) - return link - - -def _working_values(link) -> list[bool]: - """Sequence of `working` booleans passed to link.report_activity.""" - return [ - c.args[1] if len(c.args) > 1 else c.kwargs["working"] - for c in link.report_activity.call_args_list - ] - - -# Keep cadence high enough that no keep-alive fires during a fast test cycle, -# but below the TTL/2 guard. -_FAST = SessionConfig(working_keep_alive_seconds=4.0) - - -def _backlog_message( - room_id: str = "room-123", msg_id: str = "msg-b1" -) -> PlatformMessage: - return PlatformMessage( - id=msg_id, - room_id=room_id, - content="hi", - sender_id="user-1", - sender_type="User", - sender_name="User One", - message_type="text", - metadata={}, - created_at=datetime.now(UTC), - ) - - -class TestWiring: - @pytest.mark.asyncio - async def test_message_event_reports_true_then_false(self, mock_link, mock_handler): - ctx = ExecutionContext("room-123", mock_link, mock_handler, config=_FAST) - - await ctx._process_event(make_message_event(room_id="room-123", msg_id="m1")) - - assert _working_values(mock_link) == [True, False] - for call in mock_link.report_activity.call_args_list: - assert call.args[0] == "room-123" - - @pytest.mark.asyncio - async def test_reports_false_even_when_handler_raises(self, mock_link): - handler = AsyncMock(side_effect=RuntimeError("boom")) - ctx = ExecutionContext("room-123", mock_link, handler, config=_FAST) - - await ctx._process_event(make_message_event(room_id="room-123", msg_id="m1")) - - assert _working_values(mock_link)[-1] is False - assert _working_values(mock_link).count(False) == 1 - - @pytest.mark.asyncio - async def test_participant_event_does_not_report(self, mock_link, mock_handler): - ctx = ExecutionContext("room-123", mock_link, mock_handler, config=_FAST) - - await ctx._process_event( - make_participant_added_event(room_id="room-123", participant_id="user-2") - ) - - mock_link.report_activity.assert_not_called() - - @pytest.mark.asyncio - async def test_reconnected_event_does_not_report(self, mock_link, mock_handler): - ctx = ExecutionContext("room-123", mock_link, mock_handler, config=_FAST) - - await ctx._process_event(ReconnectedEvent(room_id="room-123")) - - mock_link.report_activity.assert_not_called() - - @pytest.mark.asyncio - async def test_hub_room_does_not_report(self, mock_link, mock_handler): - ctx = ExecutionContext( - "hub-1", mock_link, mock_handler, config=_FAST, hub_room_id="hub-1" - ) - - await ctx._process_event(make_message_event(room_id="hub-1", msg_id="m1")) - - mock_link.report_activity.assert_not_called() - - @pytest.mark.asyncio - async def test_backlog_message_reports_true_then_false( - self, mock_link, mock_handler - ): - ctx = ExecutionContext("room-123", mock_link, mock_handler, config=_FAST) - - await ctx._process_backlog_message(_backlog_message()) - - assert _working_values(mock_link) == [True, False] - - @pytest.mark.asyncio - async def test_disabled_config_does_not_report(self, mock_link, mock_handler): - ctx = ExecutionContext( - "room-123", - mock_link, - mock_handler, - config=SessionConfig(enable_working_state=False), - ) - - await ctx._process_event(make_message_event(room_id="room-123", msg_id="m1")) - - mock_link.report_activity.assert_not_called() - - @pytest.mark.asyncio - async def test_rooms_are_isolated(self, mock_handler): - link_a = _independent_link() - link_b = _independent_link() - ctx_a = ExecutionContext("room-a", link_a, mock_handler, config=_FAST) - ExecutionContext("room-b", link_b, mock_handler, config=_FAST) - - await ctx_a._process_event(make_message_event(room_id="room-a", msg_id="m1")) - - link_a.report_activity.assert_called() - link_b.report_activity.assert_not_called() - - -def _independent_link(): - link = MagicMock() - link.agent_id = "agent-123" - link.rest = MagicMock() - p = MagicMock(id="user-1", name="User One", type="User") - link.rest.agent_api_participants.list_agent_chat_participants = AsyncMock( - return_value=MagicMock(data=[p]) - ) - link.rest.agent_api_context.get_agent_chat_context = AsyncMock( - return_value=MagicMock(data=[]) - ) - link.mark_processing = AsyncMock(return_value=True) - link.mark_processed = AsyncMock(return_value=True) - link.mark_failed = AsyncMock(return_value=True) - link.report_activity = AsyncMock(return_value=True) - return link - - -@pytest.fixture -def mock_handler(): - return AsyncMock() - - -class TestSessionConfigGuards: - def test_defaults_are_valid(self): - cfg = SessionConfig() - assert cfg.enable_working_state is True - assert cfg.working_keep_alive_seconds == 3.0 - assert cfg.working_request_timeout_seconds == 2 - assert cfg.max_working_state_seconds is None - - def test_cadence_must_be_below_ttl_half(self): - with pytest.raises(ValueError): - SessionConfig(working_keep_alive_seconds=5.0) # >= 10/2 - - def test_cadence_must_be_positive(self): - with pytest.raises(ValueError): - SessionConfig(working_keep_alive_seconds=0) - - def test_timeout_must_be_below_cadence(self): - with pytest.raises(ValueError): - SessionConfig( - working_keep_alive_seconds=3.0, working_request_timeout_seconds=3 - ) - - def test_timeout_must_be_positive(self): - with pytest.raises(ValueError): - SessionConfig(working_request_timeout_seconds=0) - - def test_max_duration_if_set_must_be_positive(self): - with pytest.raises(ValueError): - SessionConfig(max_working_state_seconds=0) - - def test_max_cycle_seconds_if_set_must_be_positive(self): - with pytest.raises(ValueError, match="max_cycle_seconds"): - SessionConfig(max_cycle_seconds=0) - with pytest.raises(ValueError, match="max_cycle_seconds"): - SessionConfig(max_cycle_seconds=-1.0) - - def test_disabled_skips_guards(self): - # When disabled, odd values must not raise. - cfg = SessionConfig( - enable_working_state=False, working_keep_alive_seconds=100.0 - ) - assert cfg.enable_working_state is False - - def test_max_cycle_seconds_guard_is_independent_of_working_state(self): - # max_cycle_seconds is a hang-killer unrelated to working-state - # reporting, so its guard must still fire even when working-state - # reporting itself is disabled. - with pytest.raises(ValueError, match="max_cycle_seconds"): - SessionConfig(enable_working_state=False, max_cycle_seconds=0) diff --git a/tests/runtime/test_working_state.py b/tests/runtime/test_working_state.py deleted file mode 100644 index 8b0a1bd02..000000000 --- a/tests/runtime/test_working_state.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Tests for WorkingStateReporter (boolean working-state keep-alive).""" - -from __future__ import annotations - -import asyncio - -import pytest - -from band.runtime.working_state import WorkingStateReporter - - -async def _wait_for(predicate, timeout: float = 2.0) -> None: - """Poll until predicate() is truthy or raise on timeout (non-brittle wait).""" - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - while loop.time() < deadline: - if predicate(): - return - await asyncio.sleep(0.005) - raise AssertionError("condition not met within timeout") - - -class Recorder: - """Async report callback that records the sequence of working values.""" - - def __init__(self, *, fail: bool = False) -> None: - self.calls: list[bool] = [] - self.fail = fail - - async def __call__(self, working: bool) -> bool: - self.calls.append(working) - if self.fail: - raise RuntimeError("report failed") - return True - - -class TestStartStop: - @pytest.mark.asyncio - async def test_start_sends_true_immediately(self): - rec = Recorder() - reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) - - await reporter.start() - - assert rec.calls == [True] - await reporter.stop() - - @pytest.mark.asyncio - async def test_stop_sends_false(self): - rec = Recorder() - reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) - - await reporter.start() - await reporter.stop() - - assert rec.calls[0] is True - assert rec.calls[-1] is False - assert rec.calls.count(False) == 1 - - @pytest.mark.asyncio - async def test_stop_without_start_is_noop(self): - rec = Recorder() - reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) - - await reporter.stop() - - assert rec.calls == [] - - @pytest.mark.asyncio - async def test_disabled_reports_nothing(self): - rec = Recorder() - reporter = WorkingStateReporter(rec, keep_alive_seconds=0.01, enabled=False) - - await reporter.start() - await asyncio.sleep(0.05) - await reporter.stop() - - assert rec.calls == [] - - -class TestKeepAlive: - @pytest.mark.asyncio - async def test_refreshes_true_on_cadence(self): - rec = Recorder() - reporter = WorkingStateReporter(rec, keep_alive_seconds=0.02) - - await reporter.start() - # initial true + at least two refreshes - await _wait_for(lambda: rec.calls.count(True) >= 3) - await reporter.stop() - - assert rec.calls.count(True) >= 3 - assert rec.calls[-1] is False - - @pytest.mark.asyncio - async def test_reentrant_start_does_not_duplicate_keepalive(self): - rec = Recorder() - reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) - - await reporter.start() - await reporter.start() # already active -> no-op - - assert rec.calls == [True] # only one initial true, one task - await reporter.stop() - - -class TestOrdering: - @pytest.mark.asyncio - async def test_false_always_after_true_even_on_ultra_short_cycle(self): - rec = Recorder() - reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) - - # Ultra-short cycle: start then immediately stop. - await reporter.start() - await reporter.stop() - - # The lock must guarantee true is sent (and lands) before false. - assert rec.calls[0] is True - assert rec.calls[-1] is False - # No false appears before the first true. - first_false = rec.calls.index(False) - first_true = rec.calls.index(True) - assert first_true < first_false - - -class TestCancellation: - @pytest.mark.asyncio - async def test_false_sent_even_when_cycle_cancelled(self): - rec = Recorder() - reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) - - async def cycle(): - await reporter.start() - try: - await asyncio.sleep(100) # long-running reasoning - finally: - await reporter.stop() - - task = asyncio.create_task(cycle()) - await _wait_for(lambda: rec.calls == [True]) - - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - - # The shielded false-send must have completed despite the cancel. - await _wait_for(lambda: rec.calls and rec.calls[-1] is False) - assert rec.calls.count(False) == 1 - - def test_stop_sends_false_when_the_keepalive_loop_has_closed(self) -> None: - rec = Recorder() - reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) - with asyncio.Runner() as runner: - runner.run(reporter.start()) - - resumed = asyncio.new_event_loop() - try: - resumed.run_until_complete(reporter.stop()) - finally: - resumed.close() - - assert rec.calls == [True, False] - - -class TestFailureSwallowed: - @pytest.mark.asyncio - async def test_report_exception_does_not_propagate(self): - rec = Recorder(fail=True) - reporter = WorkingStateReporter(rec, keep_alive_seconds=0.02) - - # Neither start nor the keep-alive task nor stop may raise. - await reporter.start() - await asyncio.sleep(0.05) - await reporter.stop() - - assert rec.calls # attempts were made - - -class TestMaxDuration: - @pytest.mark.asyncio - async def test_keepalive_stops_after_max_but_stop_still_sends_false(self): - rec = Recorder() - reporter = WorkingStateReporter( - rec, keep_alive_seconds=0.02, max_working_state_seconds=0.05 - ) - - await reporter.start() - # Let the cap elapse and the keep-alive go quiet. - await asyncio.sleep(0.2) - count_after_cap = rec.calls.count(True) - await asyncio.sleep(0.1) - # No further true pings after the cap. - assert rec.calls.count(True) == count_after_cap - - # stop() still sends the authoritative false. - await reporter.stop() - assert rec.calls[-1] is False - - -class TestValidation: - def test_non_positive_cadence_rejected(self): - with pytest.raises(ValueError): - WorkingStateReporter(Recorder(), keep_alive_seconds=0) diff --git a/tests/runtime/workingstate/__init__.py b/tests/runtime/workingstate/__init__.py new file mode 100644 index 000000000..9d48db4f9 --- /dev/null +++ b/tests/runtime/workingstate/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/runtime/workingstate/kit.py b/tests/runtime/workingstate/kit.py new file mode 100644 index 000000000..ee553bf4c --- /dev/null +++ b/tests/runtime/workingstate/kit.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import asyncio + + +async def _wait_for(predicate, timeout: float = 2.0) -> None: + """Poll until predicate() is truthy or raise on timeout (non-brittle wait).""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + if predicate(): + return + await asyncio.sleep(0.005) + raise AssertionError("condition not met within timeout") + + +class Recorder: + """Async report callback that records the sequence of working values.""" + + def __init__(self, *, fail: bool = False) -> None: + self.calls: list[bool] = [] + self.fail = fail + + async def __call__(self, working: bool) -> bool: + self.calls.append(working) + if self.fail: + raise RuntimeError("report failed") + return True diff --git a/tests/runtime/workingstate/test_cancellation.py b/tests/runtime/workingstate/test_cancellation.py new file mode 100644 index 000000000..9cb55c023 --- /dev/null +++ b/tests/runtime/workingstate/test_cancellation.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from band.runtime.working_state import WorkingStateReporter +from tests.runtime.workingstate.kit import Recorder, _wait_for + + +class TestCancellation: + @pytest.mark.asyncio + async def test_false_sent_even_when_cycle_cancelled(self): + rec = Recorder() + reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) + + async def cycle(): + await reporter.start() + try: + await asyncio.sleep(100) # long-running reasoning + finally: + await reporter.stop() + + task = asyncio.create_task(cycle()) + await _wait_for(lambda: rec.calls == [True]) + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + # The shielded false-send must have completed despite the cancel. + await _wait_for(lambda: rec.calls and rec.calls[-1] is False) + assert rec.calls.count(False) == 1 + + def test_stop_sends_false_when_the_keepalive_loop_has_closed(self) -> None: + rec = Recorder() + reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) + with asyncio.Runner() as runner: + runner.run(reporter.start()) + + resumed = asyncio.new_event_loop() + try: + resumed.run_until_complete(reporter.stop()) + finally: + resumed.close() + + assert rec.calls == [True, False] diff --git a/tests/runtime/workingstate/test_failureswallowed.py b/tests/runtime/workingstate/test_failureswallowed.py new file mode 100644 index 000000000..0dbe23eef --- /dev/null +++ b/tests/runtime/workingstate/test_failureswallowed.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from band.runtime.working_state import WorkingStateReporter +from tests.runtime.workingstate.kit import Recorder + + +class TestFailureSwallowed: + @pytest.mark.asyncio + async def test_report_exception_does_not_propagate(self): + rec = Recorder(fail=True) + reporter = WorkingStateReporter(rec, keep_alive_seconds=0.02) + + # Neither start nor the keep-alive task nor stop may raise. + await reporter.start() + await asyncio.sleep(0.05) + await reporter.stop() + + assert rec.calls # attempts were made diff --git a/tests/runtime/workingstate/test_keepalive.py b/tests/runtime/workingstate/test_keepalive.py new file mode 100644 index 000000000..ee7a06a58 --- /dev/null +++ b/tests/runtime/workingstate/test_keepalive.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import pytest + +from band.runtime.working_state import WorkingStateReporter +from tests.runtime.workingstate.kit import Recorder, _wait_for + + +class TestKeepAlive: + @pytest.mark.asyncio + async def test_refreshes_true_on_cadence(self): + rec = Recorder() + reporter = WorkingStateReporter(rec, keep_alive_seconds=0.02) + + await reporter.start() + # initial true + at least two refreshes + await _wait_for(lambda: rec.calls.count(True) >= 3) + await reporter.stop() + + assert rec.calls.count(True) >= 3 + assert rec.calls[-1] is False + + @pytest.mark.asyncio + async def test_reentrant_start_does_not_duplicate_keepalive(self): + rec = Recorder() + reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) + + await reporter.start() + await reporter.start() # already active -> no-op + + assert rec.calls == [True] # only one initial true, one task + await reporter.stop() diff --git a/tests/runtime/workingstate/test_maxduration.py b/tests/runtime/workingstate/test_maxduration.py new file mode 100644 index 000000000..b16c313f4 --- /dev/null +++ b/tests/runtime/workingstate/test_maxduration.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from band.runtime.working_state import WorkingStateReporter +from tests.runtime.workingstate.kit import Recorder + + +class TestMaxDuration: + @pytest.mark.asyncio + async def test_keepalive_stops_after_max_but_stop_still_sends_false(self): + rec = Recorder() + reporter = WorkingStateReporter( + rec, keep_alive_seconds=0.02, max_working_state_seconds=0.05 + ) + + await reporter.start() + # Let the cap elapse and the keep-alive go quiet. + await asyncio.sleep(0.2) + count_after_cap = rec.calls.count(True) + await asyncio.sleep(0.1) + # No further true pings after the cap. + assert rec.calls.count(True) == count_after_cap + + # stop() still sends the authoritative false. + await reporter.stop() + assert rec.calls[-1] is False diff --git a/tests/runtime/workingstate/test_ordering.py b/tests/runtime/workingstate/test_ordering.py new file mode 100644 index 000000000..d15ff8289 --- /dev/null +++ b/tests/runtime/workingstate/test_ordering.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import pytest + +from band.runtime.working_state import WorkingStateReporter +from tests.runtime.workingstate.kit import Recorder + + +class TestOrdering: + @pytest.mark.asyncio + async def test_false_always_after_true_even_on_ultra_short_cycle(self): + rec = Recorder() + reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) + + # Ultra-short cycle: start then immediately stop. + await reporter.start() + await reporter.stop() + + # The lock must guarantee true is sent (and lands) before false. + assert rec.calls[0] is True + assert rec.calls[-1] is False + # No false appears before the first true. + first_false = rec.calls.index(False) + first_true = rec.calls.index(True) + assert first_true < first_false diff --git a/tests/runtime/workingstate/test_startstop.py b/tests/runtime/workingstate/test_startstop.py new file mode 100644 index 000000000..0b9f2bfb5 --- /dev/null +++ b/tests/runtime/workingstate/test_startstop.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from band.runtime.working_state import WorkingStateReporter +from tests.runtime.workingstate.kit import Recorder + + +class TestStartStop: + @pytest.mark.asyncio + async def test_start_sends_true_immediately(self): + rec = Recorder() + reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) + + await reporter.start() + + assert rec.calls == [True] + await reporter.stop() + + @pytest.mark.asyncio + async def test_stop_sends_false(self): + rec = Recorder() + reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) + + await reporter.start() + await reporter.stop() + + assert rec.calls[0] is True + assert rec.calls[-1] is False + assert rec.calls.count(False) == 1 + + @pytest.mark.asyncio + async def test_stop_without_start_is_noop(self): + rec = Recorder() + reporter = WorkingStateReporter(rec, keep_alive_seconds=10.0) + + await reporter.stop() + + assert rec.calls == [] + + @pytest.mark.asyncio + async def test_disabled_reports_nothing(self): + rec = Recorder() + reporter = WorkingStateReporter(rec, keep_alive_seconds=0.01, enabled=False) + + await reporter.start() + await asyncio.sleep(0.05) + await reporter.stop() + + assert rec.calls == [] diff --git a/tests/runtime/workingstate/test_validation.py b/tests/runtime/workingstate/test_validation.py new file mode 100644 index 000000000..36ffbbc15 --- /dev/null +++ b/tests/runtime/workingstate/test_validation.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import pytest + +from band.runtime.working_state import WorkingStateReporter +from tests.runtime.workingstate.kit import Recorder + + +class TestValidation: + def test_non_positive_cadence_rejected(self): + with pytest.raises(ValueError): + WorkingStateReporter(Recorder(), keep_alive_seconds=0) From 8ec26b33cf3ff15f27d6149daa27a0d4ea68c17d Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 18 Oct 2026 10:25:08 +0300 Subject: [PATCH 02/33] fix: Preserve standalone worker binding across tool wrappers --- src/band/runtime/tools/agent.py | 7 +++++-- tests/runtime/helpers.py | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/band/runtime/tools/agent.py b/src/band/runtime/tools/agent.py index 6951d5492..88d53634b 100644 --- a/src/band/runtime/tools/agent.py +++ b/src/band/runtime/tools/agent.py @@ -388,8 +388,11 @@ def from_context( return tools def register_turn_task(self, owner: TurnTask) -> None: - if self._scope is not None and self._ctx is not None: - self._ctx.register_turn_task(self._scope, owner) + if self._scope is not None: + if self._ctx is None: + self._scope.bind(owner) + else: + self._ctx.register_turn_task(self._scope, owner) def _abort_stopped_post(self, error: RoomExecutionStoppedError) -> None: if self._ctx is not None and self._scope is not None: diff --git a/tests/runtime/helpers.py b/tests/runtime/helpers.py index 0a96e4340..0a04accde 100644 --- a/tests/runtime/helpers.py +++ b/tests/runtime/helpers.py @@ -296,13 +296,16 @@ class PostingPlatform(LifecyclePlatform): def answer(self, request: httpx.Request) -> httpx.Response: tail = request.url.path.rsplit("/", 1)[-1] if tail == "participants": - return page([ALICE]) + return page([{**ALICE, "role": "member", "status": "active"}]) if request.method == "POST" and tail == "events": return httpx.Response( - 201, json={"data": {"id": "e-1", "message_type": "event"}} + 201, + json={"data": {"id": "e-1", "message_type": "event", "success": True}}, ) if request.method == "POST" and tail == "messages": - return httpx.Response(201, json={"data": {"id": "m-1", "recipients": []}}) + return httpx.Response( + 201, json={"data": {"id": "m-1", "recipients": [], "success": True}} + ) return super().answer(request) From 1476457ca6f33d8eb0f43918727d9cf16c88455b Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 18 Oct 2026 11:09:08 +0300 Subject: [PATCH 03/33] test: Avoid the E2E peer fixture reservation in runtime tests --- tests/runtime/activityownership/conftest.py | 6 +-- .../activityownership/test_lifetime.py | 46 +++++++++---------- .../activityownership/test_opencode.py | 10 ++-- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/tests/runtime/activityownership/conftest.py b/tests/runtime/activityownership/conftest.py index 916e0ae7b..16bcf6bfc 100644 --- a/tests/runtime/activityownership/conftest.py +++ b/tests/runtime/activityownership/conftest.py @@ -20,16 +20,16 @@ @pytest.fixture -async def peer() -> AsyncIterator[LifecyclePlatform]: +async def activity_platform() -> AsyncIterator[LifecyclePlatform]: yield ActivityPlatform() @pytest.fixture async def execution( - peer: LifecyclePlatform, + activity_platform: LifecyclePlatform, ) -> AsyncIterator[Callable[..., ExecutionContext]]: contexts: list[ExecutionContext] = [] - async with rest_client_over(peer.answer) as rest: + async with rest_client_over(activity_platform.answer) as rest: def create( handler: Callable[[ExecutionContext, PlatformEvent], Awaitable[None]], diff --git a/tests/runtime/activityownership/test_lifetime.py b/tests/runtime/activityownership/test_lifetime.py index 1dcf0f921..c6d8adaf5 100644 --- a/tests/runtime/activityownership/test_lifetime.py +++ b/tests/runtime/activityownership/test_lifetime.py @@ -17,7 +17,7 @@ class TestProviderLifetime: @pytest.mark.looptime async def test_detached_worker_keeps_heartbeat_across_busy_delivery( - self, execution: Callable[..., ExecutionContext], peer: LifecyclePlatform + self, execution: Callable[..., ExecutionContext], activity_platform: LifecyclePlatform ) -> None: release = asyncio.Event() workers: list[TurnTask] = [] @@ -32,16 +32,16 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await ctx._process_event(make_message_event(msg_id="original")) await asyncio.sleep(3.1) await ctx._process_event(make_message_event(msg_id="busy")) - assert peer.activity_reports == [KEEP_ALIVE, KEEP_ALIVE] - assert peer.marked("processed") == ["original", "busy"] + assert activity_platform.activity_reports == [KEEP_ALIVE, KEEP_ALIVE] + assert activity_platform.marked("processed") == ["original", "busy"] release.set() await workers[0].task await asyncio.sleep(0) await ctx._join_activity_completion() - assert peer.activity_reports[-1] == IDLE + assert activity_platform.activity_reports[-1] == IDLE async def test_worker_completion_does_not_clear_running_handler( - self, execution: Callable[..., ExecutionContext], peer: LifecyclePlatform + self, execution: Callable[..., ExecutionContext], activity_platform: LifecyclePlatform ) -> None: finished, release = asyncio.Event(), asyncio.Event() @@ -55,16 +55,16 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: ctx = execution(handler) turn = asyncio.create_task(ctx._process_event(make_message_event())) await finished.wait() - assert peer.activity_reports == [KEEP_ALIVE] + assert activity_platform.activity_reports == [KEEP_ALIVE] release.set() await turn - assert peer.activity_reports == [KEEP_ALIVE, IDLE] + assert activity_platform.activity_reports == [KEEP_ALIVE, IDLE] @pytest.mark.parametrize("mode", [ControlMode.STOP, ControlMode.INTERRUPT]) async def test_detached_control_cancels_worker_preserving_settled_ack( self, execution: Callable[..., ExecutionContext], - peer: LifecyclePlatform, + activity_platform: LifecyclePlatform, mode: ControlMode, ) -> None: workers: list[TurnTask] = [] @@ -83,12 +83,12 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await asyncio.gather(workers[0].task, return_exceptions=True) await ctx._join_activity_completion() assert workers[0].task.cancelled() - assert peer.marked("processed") == ["original"] - assert peer.activity_reports == [KEEP_ALIVE, IDLE] + assert activity_platform.marked("processed") == ["original"] + assert activity_platform.activity_reports == [KEEP_ALIVE, IDLE] @pytest.mark.looptime async def test_detached_heartbeat_stop_preserves_ack( - self, execution: Callable[..., ExecutionContext], peer: LifecyclePlatform + self, execution: Callable[..., ExecutionContext], activity_platform: LifecyclePlatform ) -> None: workers: list[TurnTask] = [] @@ -104,15 +104,15 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: ctx = execution(handler) await ctx._process_event(make_message_event(msg_id="original")) await ctx._process_event(make_message_event(msg_id="busy")) - peer.stopped = True + activity_platform.stopped = True await asyncio.sleep(3.1) assert workers[0].task.cancelled() - assert peer.marked("processed") == ["original", "busy"] - assert peer.activity_reports[-1] == IDLE + assert activity_platform.marked("processed") == ["original", "busy"] + assert activity_platform.activity_reports[-1] == IDLE @pytest.mark.looptime async def test_busy_delivery_keeps_original_cap_deadline( - self, execution: Callable[..., ExecutionContext], peer: LifecyclePlatform + self, execution: Callable[..., ExecutionContext], activity_platform: LifecyclePlatform ) -> None: release = asyncio.Event() workers: list[TurnTask] = [] @@ -131,16 +131,16 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await asyncio.sleep(3.1) await ctx._process_event(make_message_event(msg_id="busy")) await asyncio.sleep(6) - assert peer.activity_reports == [KEEP_ALIVE, KEEP_ALIVE] + assert activity_platform.activity_reports == [KEEP_ALIVE, KEEP_ALIVE] assert not workers[0].task.done() release.set() await workers[0].task await asyncio.sleep(0) await ctx._join_activity_completion() - assert peer.activity_reports[-1] == IDLE + assert activity_platform.activity_reports[-1] == IDLE async def test_shutdown_clears_reporting_without_cancelling_detached_worker( - self, execution: Callable[..., ExecutionContext], peer: LifecyclePlatform + self, execution: Callable[..., ExecutionContext], activity_platform: LifecyclePlatform ) -> None: release = asyncio.Event() workers: list[TurnTask] = [] @@ -155,14 +155,14 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: ctx = execution(handler) await ctx._process_event(make_message_event()) await ctx.stop() - assert peer.activity_reports == [KEEP_ALIVE, IDLE] + assert activity_platform.activity_reports == [KEEP_ALIVE, IDLE] assert not workers[0].task.done() release.set() await workers[0].task - assert peer.activity_reports == [KEEP_ALIVE, IDLE] + assert activity_platform.activity_reports == [KEEP_ALIVE, IDLE] async def test_new_worker_admitted_after_previous_completion_gets_own_indicator( - self, execution: Callable[..., ExecutionContext], peer: LifecyclePlatform + self, execution: Callable[..., ExecutionContext], activity_platform: LifecyclePlatform ) -> None: releases = [asyncio.Event(), asyncio.Event()] entered, admitted = asyncio.Event(), asyncio.Event() @@ -190,9 +190,9 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await ctx._join_activity_completion() admitted.set() await turn - assert peer.activity_reports == [KEEP_ALIVE, IDLE, KEEP_ALIVE] + assert activity_platform.activity_reports == [KEEP_ALIVE, IDLE, KEEP_ALIVE] releases[1].set() await workers[1].task await asyncio.sleep(0) await ctx._join_activity_completion() - assert peer.activity_reports == [KEEP_ALIVE, IDLE, KEEP_ALIVE, IDLE] + assert activity_platform.activity_reports == [KEEP_ALIVE, IDLE, KEEP_ALIVE, IDLE] diff --git a/tests/runtime/activityownership/test_opencode.py b/tests/runtime/activityownership/test_opencode.py index 7fb26af66..7d8e09879 100644 --- a/tests/runtime/activityownership/test_opencode.py +++ b/tests/runtime/activityownership/test_opencode.py @@ -27,7 +27,7 @@ class TestOpenCodeLifetime: async def test_parked_native_turn_dual_dispatch_aborts_remote_once( self, execution: Callable[..., ExecutionContext], - peer: LifecyclePlatform, + activity_platform: LifecyclePlatform, mode: ControlMode, ) -> None: client = FakeOpencodeClient( @@ -59,8 +59,8 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: ctx = execution(handler) try: await ctx._process_event(make_message_event(msg_id="original")) - assert peer.activity_reports == [KEEP_ALIVE] - assert peer.marked("processed") == ["original"] + assert activity_platform.activity_reports == [KEEP_ALIVE] + assert activity_platform.marked("processed") == ["original"] assert ctx.interrupt(kind=mode) notify = asyncio.create_task(adapter.on_interrupt(ROOM_ID, mode)) await aborting.wait() @@ -70,8 +70,8 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await ctx._join_activity_completion() assert client.aborted_sessions == ["sess-1"] assert cleaned.is_set() - assert peer.activity_reports == [KEEP_ALIVE, IDLE] - assert peer.marked("processed") == ["original"] + assert activity_platform.activity_reports == [KEEP_ALIVE, IDLE] + assert activity_platform.marked("processed") == ["original"] finally: release.set() await adapter.on_cleanup(ROOM_ID) From 8bfeac07a5dc34eaf450fd6b1b3f418a9f41c8e8 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 18 Oct 2026 11:11:32 +0300 Subject: [PATCH 04/33] style: Format the renamed activity platform fixture --- .../activityownership/test_lifetime.py | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/tests/runtime/activityownership/test_lifetime.py b/tests/runtime/activityownership/test_lifetime.py index c6d8adaf5..225251892 100644 --- a/tests/runtime/activityownership/test_lifetime.py +++ b/tests/runtime/activityownership/test_lifetime.py @@ -17,7 +17,9 @@ class TestProviderLifetime: @pytest.mark.looptime async def test_detached_worker_keeps_heartbeat_across_busy_delivery( - self, execution: Callable[..., ExecutionContext], activity_platform: LifecyclePlatform + self, + execution: Callable[..., ExecutionContext], + activity_platform: LifecyclePlatform, ) -> None: release = asyncio.Event() workers: list[TurnTask] = [] @@ -41,7 +43,9 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: assert activity_platform.activity_reports[-1] == IDLE async def test_worker_completion_does_not_clear_running_handler( - self, execution: Callable[..., ExecutionContext], activity_platform: LifecyclePlatform + self, + execution: Callable[..., ExecutionContext], + activity_platform: LifecyclePlatform, ) -> None: finished, release = asyncio.Event(), asyncio.Event() @@ -88,7 +92,9 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: @pytest.mark.looptime async def test_detached_heartbeat_stop_preserves_ack( - self, execution: Callable[..., ExecutionContext], activity_platform: LifecyclePlatform + self, + execution: Callable[..., ExecutionContext], + activity_platform: LifecyclePlatform, ) -> None: workers: list[TurnTask] = [] @@ -112,7 +118,9 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: @pytest.mark.looptime async def test_busy_delivery_keeps_original_cap_deadline( - self, execution: Callable[..., ExecutionContext], activity_platform: LifecyclePlatform + self, + execution: Callable[..., ExecutionContext], + activity_platform: LifecyclePlatform, ) -> None: release = asyncio.Event() workers: list[TurnTask] = [] @@ -140,7 +148,9 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: assert activity_platform.activity_reports[-1] == IDLE async def test_shutdown_clears_reporting_without_cancelling_detached_worker( - self, execution: Callable[..., ExecutionContext], activity_platform: LifecyclePlatform + self, + execution: Callable[..., ExecutionContext], + activity_platform: LifecyclePlatform, ) -> None: release = asyncio.Event() workers: list[TurnTask] = [] @@ -162,7 +172,9 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: assert activity_platform.activity_reports == [KEEP_ALIVE, IDLE] async def test_new_worker_admitted_after_previous_completion_gets_own_indicator( - self, execution: Callable[..., ExecutionContext], activity_platform: LifecyclePlatform + self, + execution: Callable[..., ExecutionContext], + activity_platform: LifecyclePlatform, ) -> None: releases = [asyncio.Event(), asyncio.Event()] entered, admitted = asyncio.Event(), asyncio.Event() @@ -195,4 +207,9 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await workers[1].task await asyncio.sleep(0) await ctx._join_activity_completion() - assert activity_platform.activity_reports == [KEEP_ALIVE, IDLE, KEEP_ALIVE, IDLE] + assert activity_platform.activity_reports == [ + KEEP_ALIVE, + IDLE, + KEEP_ALIVE, + IDLE, + ] From a397d8cb905d5316a87f62da23c8c5af1895e78e Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 18 Oct 2026 14:03:47 +0300 Subject: [PATCH 05/33] test: Use real time for randomized scheduler grace controls --- tests/runtime/turnadmission/test_grace_controls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/runtime/turnadmission/test_grace_controls.py b/tests/runtime/turnadmission/test_grace_controls.py index 42bc64fdd..0e2e4f44b 100644 --- a/tests/runtime/turnadmission/test_grace_controls.py +++ b/tests/runtime/turnadmission/test_grace_controls.py @@ -24,8 +24,8 @@ WITHIN_GRACE = CYCLE_CANCEL_GRACE_SECONDS / 2 +# Real time avoids looptime rounding randomized reconciliation deadlines down. @pytest.mark.timeout(10) -@pytest.mark.looptime class TestGraceControls: """STOP during the platform-stop observer's grace leaves the next turn unstarted.""" From 0342d962d41cf3234daff3781f5d4e902b2be521 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 18 Oct 2026 10:17:52 +0300 Subject: [PATCH 06/33] feat: Deliver compatible activity detail through the working-state reporter --- docs/turn-outcome.md | 11 ++ src/band/platform/activity.py | 95 ++++++++++ src/band/platform/link.py | 4 + src/band/platform/message_lifecycle.py | 73 ++++++-- src/band/runtime/execution.py | 59 +++++- src/band/runtime/sessionconfig.py | 7 +- src/band/runtime/tools/agent.py | 3 + src/band/runtime/working_state.py | 173 +++++++++++++----- tests/platform/activity/conftest.py | 38 ++++ tests/platform/activity/kit.py | 44 +++++ tests/platform/activity/test_coordination.py | 79 ++++++++ tests/platform/activity/test_deadlines.py | 44 +++++ tests/platform/activity/test_fallback.py | 64 +++++++ tests/platform/activity/test_versions.py | 58 ++++++ tests/runtime/activityownership/conftest.py | 21 ++- tests/runtime/activityownership/kit.py | 21 +++ .../runtime/activityownership/test_detail.py | 144 +++++++++++++++ tests/runtime/conftest.py | 14 ++ tests/runtime/workingstate/test_detail.py | 106 +++++++++++ 19 files changed, 984 insertions(+), 74 deletions(-) create mode 100644 src/band/platform/activity.py create mode 100644 tests/platform/activity/conftest.py create mode 100644 tests/platform/activity/kit.py create mode 100644 tests/platform/activity/test_coordination.py create mode 100644 tests/platform/activity/test_deadlines.py create mode 100644 tests/platform/activity/test_fallback.py create mode 100644 tests/platform/activity/test_versions.py create mode 100644 tests/runtime/activityownership/test_detail.py create mode 100644 tests/runtime/workingstate/test_detail.py diff --git a/docs/turn-outcome.md b/docs/turn-outcome.md index 6951fad99..d674c64a4 100644 --- a/docs/turn-outcome.md +++ b/docs/turn-outcome.md @@ -410,3 +410,14 @@ once. It does not rewrite the original delivery's settled acknowledgement. Explicit interruption invalidates its activity; leave and shutdown clear the indicator while adapter residency retains responsibility for released workers. The authoritative idle report finishes before a replacement reports working. + +`SessionConfig.enable_working_state` controls both the indicator and activity +detail, independently of chat narration and tool capabilities. Changes and +keep-alives share one serialized reporter. The duration cap stops both kinds +of refresh without cancelling provider work; actual completion still clears. + +Detail compatibility is negotiated once per process using the first platform's +`agent_version`. Failed or unsupported discovery preserves boolean reporting. +A detail-bearing HTTP 422 permanently disables detail across all links in the +process and retries that report once without detail, within its original budget. +The version lookup uses at most half that budget, reserving time for liveness. diff --git a/src/band/platform/activity.py b/src/band/platform/activity.py new file mode 100644 index 000000000..beb48c74e --- /dev/null +++ b/src/band/platform/activity.py @@ -0,0 +1,95 @@ +"""Process-wide negotiation for optional room activity detail. + +The first endpoint defines support for the process. A detail rejection on any +endpoint permanently downgrades every link; boolean liveness remains available. +""" + +from __future__ import annotations + +import asyncio +import logging +import re +from concurrent.futures import Future +from threading import Lock + +import httpx + +logger = logging.getLogger(__name__) +MINIMUM_ACTIVITY_VERSION = (1, 25, 0) +RELEASE_VERSION = re.compile(r"([0-9]+)\.([0-9]+)\.([0-9]+)") + + +class ActivitySupport: + """Share discovery without retaining primitives owned by an asyncio loop.""" + + def __init__(self) -> None: + self._lock = Lock() + self._discovery: Future[bool] | None = None + self._disabled = False + + @property + def supported(self) -> bool: + with self._lock: + return ( + not self._disabled + and self._discovery is not None + and self._discovery.done() + and self._discovery.result() + ) + + def disable(self) -> None: + with self._lock: + already_disabled = self._disabled + self._disabled = True + if not already_disabled: + logger.info( + "Activity detail rejected; using boolean reporting for this process" + ) + + async def allows(self, rest_url: str, budget: float) -> bool: + with self._lock: + if self._disabled: + return False + owner = self._discovery is None + if owner: + self._discovery = Future() + discovery = self._discovery + assert discovery is not None + if owner: + supported = False + try: + async with asyncio.timeout(budget): + supported = await self._discover(rest_url, budget) + except asyncio.CancelledError: + raise + except (httpx.HTTPError, ValueError, TimeoutError): + pass + finally: + discovery.set_result(supported) + if not supported: + logger.info( + "Activity detail unavailable; using boolean reporting for this process" + ) + else: + try: + async with asyncio.timeout(budget): + await asyncio.shield(asyncio.wrap_future(discovery)) + except TimeoutError: + return False + return self.supported + + @staticmethod + async def _discover(rest_url: str, budget: float) -> bool: + async with httpx.AsyncClient(timeout=budget) as client: + response = await client.get(f"{rest_url.rstrip('/')}/api/version") + response.raise_for_status() + data = response.json() + version = data.get("agent_version") if isinstance(data, dict) else None + match = RELEASE_VERSION.fullmatch(version) if isinstance(version, str) else None + return ( + match is not None + and tuple(map(int, match.groups())) >= MINIMUM_ACTIVITY_VERSION + ) + + +activity_support = ActivitySupport() diff --git a/src/band/platform/link.py b/src/band/platform/link.py index 524c3cd07..977d0451b 100644 --- a/src/band/platform/link.py +++ b/src/band/platform/link.py @@ -12,6 +12,7 @@ from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING +from band_rest.types.agent_activity_detail import AgentActivityDetail from band_sdk_core import AgentTopicKind, SessionState from band.client.rest import AsyncRestClient @@ -584,6 +585,7 @@ async def report_activity( room_id: str, working: bool, *, + activity: AgentActivityDetail | None = None, timeout_seconds: int = 2, on_stopped: Callable[[], None] | None = None, ) -> bool: @@ -591,6 +593,8 @@ async def report_activity( self.rest, room_id, working, + activity=activity, + rest_url=self.rest_url, timeout_seconds=timeout_seconds, on_stopped=on_stopped, ) diff --git a/src/band/platform/message_lifecycle.py b/src/band/platform/message_lifecycle.py index 2c6d1c8df..3f6ab070d 100644 --- a/src/band/platform/message_lifecycle.py +++ b/src/band/platform/message_lifecycle.py @@ -6,20 +6,27 @@ from __future__ import annotations +import asyncio import logging from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime from http import HTTPStatus +from math import ceil from typing import Any, TypeVar +from band_rest.agent_api_activity.types.report_agent_chat_activity_response import ( + ReportAgentChatActivityResponse, +) from band_rest.core.api_error import ApiError from band_rest.core.http_response import AsyncHttpResponse +from band_rest.types.agent_activity_detail import AgentActivityDetail from band_rest.types.chat_message import ChatMessage from band.client.rest import DEFAULT_REQUEST_OPTIONS, AsyncRestClient from band.core.exceptions import RoomExecutionStoppedError from band.core.types import metadata_to_dict +from band.platform.activity import activity_support from band.runtime.messages import PlatformMessage logger = logging.getLogger(__name__) @@ -167,6 +174,8 @@ async def report_activity( room_id: str, working: bool, *, + activity: AgentActivityDetail | None = None, + rest_url: str | None = None, timeout_seconds: int = 2, on_stopped: Callable[[], None] | None = None, ) -> bool: @@ -179,26 +188,27 @@ async def report_activity( the backstop and activity reporting must never break message processing. - ``timeout_seconds`` bounds each POST so a slow/half-open endpoint - can't stall the keep-alive or wedge teardown. Retries are off: a - dropped keep-alive gets re-sent next cadence tick, and a dropped - ``false`` is cleared by the platform TTL either way — retrying would - only add latency. + One deadline bounds discovery, POST and the sole compatibility retry. + A rejected detail permanently downgrades the process to boolean reports; + other failures wait for the next keep-alive or platform TTL. A successful ``working=True`` report echoed as ``False`` identifies a stopped execution. Notify ``on_stopped`` separately from the request's success boolean; transport failures never imply STOP. """ try: - response = await rest.agent_api_activity.report_agent_chat_activity( - chat_id=room_id, - working=working, - request_options={ - "timeout_in_seconds": timeout_seconds, - "max_retries": 0, - }, - ) - stopped = working and response.data.working is False + async with asyncio.timeout(timeout_seconds): + deadline = asyncio.get_running_loop().time() + timeout_seconds + detail = activity if working else None + if detail is not None and ( + rest_url is None + or not await activity_support.allows(rest_url, timeout_seconds / 2) + ): + detail = None + response = await self._post_activity( + rest, room_id, working, detail, deadline + ) + stopped = working and response.data.working is False except Exception as e: # noqa: BLE001 -- best-effort event emission must not crash the turn/link if not self._activity_report_failing: self._activity_report_failing = True @@ -224,6 +234,41 @@ async def report_activity( on_stopped() return True + @staticmethod + async def _post_activity( + rest: AsyncRestClient, + room_id: str, + working: bool, + activity: AgentActivityDetail | None, + deadline: float, + ) -> ReportAgentChatActivityResponse: + kwargs: dict[str, Any] = {} + if activity is not None and activity_support.supported: + kwargs["activity"] = activity + for attempt in range(2): + try: + return await rest.agent_api_activity.report_agent_chat_activity( + chat_id=room_id, + working=working, + request_options={ + "timeout_in_seconds": max( + 1, ceil(deadline - asyncio.get_running_loop().time()) + ), + "max_retries": 0, + }, + **kwargs, + ) + except ApiError as error: + if ( + attempt + or "activity" not in kwargs + or error.status_code != HTTPStatus.UNPROCESSABLE_ENTITY + ): + raise + activity_support.disable() + kwargs.clear() + raise AssertionError("Activity compatibility retry did not return") + async def get_next_message( self, rest: AsyncRestClient, room_id: str ) -> PlatformMessage | None: diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index 701f25ded..89df6db79 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -19,6 +19,7 @@ import logging from collections import deque from collections.abc import Awaitable, Callable, Iterator +from dataclasses import dataclass from datetime import UTC, datetime from enum import Enum, StrEnum from functools import partial @@ -30,6 +31,7 @@ runtime_checkable, ) +from band_rest.types.agent_activity_detail import AgentActivityDetail from band_sdk_core import ( AgentFailure, ClaimRegistry, @@ -84,7 +86,7 @@ from band.runtime.resync import ResyncSchedule from band.runtime.sessionconfig import SessionConfig from band.runtime.tools.agent import AgentTools -from band.runtime.working_state import WorkingStateReporter +from band.runtime.working_state import ActivityObserver, WorkingStateReporter if TYPE_CHECKING: from band.platform.link import BandLink @@ -281,6 +283,18 @@ async def resume_room(self) -> None: ExecutionHandler = Callable[["ExecutionContext", PlatformEvent], Awaitable[None]] +@dataclass(frozen=True) +class ScopeActivityObserver: + context: ExecutionContext + scope: TurnScope + + async def update(self, activity: AgentActivityDetail) -> None: + await self.context.observe_activity(self.scope, activity=activity) + + async def set_manual_wait(self, waiting: bool) -> None: + await self.context.observe_activity(self.scope, waiting=waiting) + + class ExecutionContext: """ Default execution: context accumulation model. @@ -431,10 +445,9 @@ def __init__( # The first stop's graceful result; a retry only settles retained work. self._stop_graceful = True - # Optional seam for clearing user-visible activity state ("reasoning…") - # when a cycle is interrupted/stopped. Filled by the activity-signal - # work; a no-op (None) here. - self._on_activity_clear: Callable[[], Awaitable[None]] | None = None + self._on_activity_clear: Callable[[], Awaitable[None]] | None = ( + self._clear_scope_activity + ) @property def thread_id(self) -> str: @@ -1853,9 +1866,11 @@ async def _invoke_handler(self, event: PlatformEvent) -> None: async def _report_working_state(self, working: bool) -> bool: """Report the room's boolean working state (wired into the reporter).""" + detail = self._working_reporter.activity if working else None return await self.link.report_activity( self.room_id, working, + **({"activity": detail} if detail is not None else {}), timeout_seconds=self.config.working_request_timeout_seconds, on_stopped=partial( self._observe_activity_stop, @@ -1919,7 +1934,39 @@ async def _start_activity(self, scope: TurnScope) -> None: if self._activity_owner is not scope: self._activity_stop_scope = None self._activity_owner = scope - await self._working_reporter.start() + await self._working_reporter.start(owner=scope) + + def activity_observer(self, scope: TurnScope | None) -> ActivityObserver | None: + return ScopeActivityObserver(self, scope) if scope is not None else None + + def owns_activity(self, scope: TurnScope) -> bool: + return self._activity_owner is scope and not scope.aborted and not self._closing + + async def observe_activity( + self, + scope: TurnScope, + *, + activity: AgentActivityDetail | None = None, + waiting: bool | None = None, + ) -> None: + if ( + self._activity_owner is None + and scope is self.current_scope + and not scope.settled + ): + await self._acquire_activity(scope) + await self._working_reporter.update( + current=partial(self.owns_activity, scope), + activity=activity, + waiting=waiting, + ) + + async def _clear_scope_activity(self) -> None: + scope = self.current_scope + if scope is not None: + await self._working_reporter.clear_detail( + current=partial(self.owns_activity, scope) + ) def register_turn_task(self, scope: TurnScope, owner: TurnTask) -> None: """Bind completion to the same owner that delivers worker cancellation.""" diff --git a/src/band/runtime/sessionconfig.py b/src/band/runtime/sessionconfig.py index 9c9b73171..9e21f6f58 100644 --- a/src/band/runtime/sessionconfig.py +++ b/src/band/runtime/sessionconfig.py @@ -43,14 +43,13 @@ class SessionConfig(BaseSettings): # Queue traffic cannot postpone a check; active handlers remain serial. idle_resync_seconds: PositiveFiniteSeconds = 60.0 - # --- Working-state (boolean "is the agent reasoning") reporting --- - # Kill-switch: disable all working-state reporting instantly if needed. + # Indicator and activity detail share this switch, including approval waits. enable_working_state: bool = True # Keep-alive cadence: how often working:true is refreshed while a cycle runs. # Must be < TTL/2 so a single missed/slow ping still lands inside the TTL. working_keep_alive_seconds: float = 3.0 - # Per-POST deadline for each activity report (must be < cadence so a slow - # POST can't pile onto the next keep-alive tick). + # Total deadline for discovery, POST and a compatibility fallback. + # Must be < cadence so a slow report cannot pile onto the next tick. working_request_timeout_seconds: int = 2 # Optional upper bound on how long we keep asserting working:true for one # cycle. When exceeded we stop refreshing (platform TTL clears it); we never diff --git a/src/band/runtime/tools/agent.py b/src/band/runtime/tools/agent.py index 88d53634b..823291c83 100644 --- a/src/band/runtime/tools/agent.py +++ b/src/band/runtime/tools/agent.py @@ -74,6 +74,7 @@ validate_tool_arguments, ) from band.runtime.turn import records_turn_effects +from band.runtime.working_state import ActivityObserver if TYPE_CHECKING: from anthropic.types import ToolParam @@ -344,6 +345,7 @@ def __init__( self._agent_id = agent_id self._ctx: ExecutionContext | None = None self._scope: TurnScope | None = None + self._activity_observer: ActivityObserver | None = None self.turn = Turn() @property @@ -384,6 +386,7 @@ def from_context( ) tools._ctx = ctx tools._scope = ctx.current_scope if bind_scope else None + tools._activity_observer = ctx.activity_observer(tools._scope) tools.turn = Turn(posts_missing_reply=ctx.config.report_turn_failures_to_room) return tools diff --git a/src/band/runtime/working_state.py b/src/band/runtime/working_state.py index 4d842c7fe..318a9aac6 100644 --- a/src/band/runtime/working_state.py +++ b/src/band/runtime/working_state.py @@ -1,26 +1,8 @@ -"""Boolean working-state keep-alive reporter. - -Owns the per-room "is the agent working" signal for a single reasoning cycle: -sends ``working: true`` when a cycle starts, refreshes it on a keep-alive -cadence while the cycle is active, and sends an authoritative ``working: false`` -when the cycle ends. The platform applies a short TTL, so if refreshes stop for -any reason (crash, disconnect, loop-block) the indicator clears on its own — the -keep-alive is a *liveness* signal, not a correctness signal. - -The reporter is transport-agnostic: it is given an async ``report(working)`` -callback (wired to ``BandLink.report_activity`` by the runtime) and never raises -into the caller's reasoning loop. - -Contract: ``report`` MUST be time-bounded. The reporter awaits it (including the -authoritative false-send during teardown), so an unbounded callback could wedge -cycle teardown and cancellation propagation. ``BandLink.report_activity`` applies -a per-POST timeout to satisfy this. - -Cadence accuracy: the keep-alive sleeps ``keep_alive_seconds`` and *then* reports, -so the effective interval between successful refreshes is -``keep_alive_seconds + report-latency``. The platform-TTL headroom is preserved by -the ``cadence < TTL/2`` guard on ``SessionConfig`` (with the per-POST timeout kept -below the cadence), so even one missed/slow ping stays within the TTL window. +"""Serialized working-state and names-only activity for one provider turn. + +The boolean callback must be time bounded. It reads immutable detail under the +same lock as changes and heartbeats. Terminal false is shielded; platform TTL +backstops failed reports. A duration cap stops refreshes without cancelling work. """ from __future__ import annotations @@ -28,12 +10,52 @@ import asyncio import logging from collections.abc import Awaitable, Callable +from typing import Protocol + +from band_rest.types.agent_activity_detail import AgentActivityDetail logger = logging.getLogger(__name__) ReportFn = Callable[[bool], Awaitable[object]] +UNKNOWN_ACTIVITY_TOOL_NAME = "unknown" + + +class ActivityObserver(Protocol): + async def update(self, activity: AgentActivityDetail) -> None: ... + + async def set_manual_wait(self, waiting: bool) -> None: ... + + +def _observer(tools: object) -> ActivityObserver | None: + return getattr(tools, "_activity_observer", None) + + +async def report_tool_activity(tools: object, name: str) -> None: + if observer := _observer(tools): + await observer.update( + AgentActivityDetail( + kind="tool_call", name=name or UNKNOWN_ACTIVITY_TOOL_NAME + ) + ) + + +async def end_tool_activity(tools: object) -> None: + if observer := _observer(tools): + await observer.update(AgentActivityDetail(kind="reasoning")) + + +async def report_memory_activity(tools: object) -> None: + if observer := _observer(tools): + await observer.update(AgentActivityDetail(kind="memory")) + + +async def set_permission_wait(tools: object, waiting: bool) -> None: + if observer := _observer(tools): + await observer.set_manual_wait(waiting) + + def _retrieve_task_error(task: asyncio.Task[None]) -> None: if not task.cancelled(): task.exception() @@ -60,6 +82,9 @@ def __init__( self._enabled = enabled self._active = False + self._cycle: object | None = None + self._ordinary = AgentActivityDetail(kind="reasoning") + self._manual_wait = False self._started_at: float | None = None self._task: asyncio.Task[None] | None = None self._false_send: asyncio.Future[None] | None = None @@ -68,19 +93,66 @@ def __init__( # (boolean payload carries no sequence number to disambiguate). self._lock = asyncio.Lock() - async def start(self) -> None: - """Begin a reasoning cycle: send ``working: true`` and arm keep-alive.""" - if not self._enabled or self._active: - # Re-entrant start while active is a no-op: the signal is already - # alive and the keep-alive task is already running. + @property + def activity(self) -> AgentActivityDetail: + """The immutable effective detail, read by the bool callback under its lock.""" + return ( + AgentActivityDetail(kind="permission") + if self._manual_wait + else self._ordinary + ) + + async def start(self, *, owner: object | None = None) -> None: + if not self._enabled: return await self._finish_false_send() - self._active = True - self._started_at = asyncio.get_running_loop().time() - await self._safe_report(True) - self._task = asyncio.create_task( - self._keep_alive(), name="working-state-keepalive" - ) + async with self._lock: + if self._active: + return + self._active = True + cycle = owner if owner is not None else object() + self._cycle = cycle + self._ordinary = AgentActivityDetail(kind="reasoning") + self._manual_wait = False + self._started_at = asyncio.get_running_loop().time() + await self._report_locked(True) + if self._cycle is cycle and self._active: + self._task = asyncio.create_task( + self._keep_alive(cycle), name="working-state-keepalive" + ) + + async def update( + self, + *, + current: Callable[[], bool], + activity: AgentActivityDetail | None = None, + waiting: bool | None = None, + ) -> None: + if not current() or not self._enabled: + return + async with self._lock: + if not current() or not self._active or self._cap_reached(): + return + previous = self.activity + if activity is not None: + self._ordinary = ( + AgentActivityDetail( + kind="tool_call", + name=activity.name or UNKNOWN_ACTIVITY_TOOL_NAME, + ) + if activity.kind == "tool_call" + else AgentActivityDetail(kind=activity.kind) + ) + if waiting is not None: + self._manual_wait = waiting + if self.activity != previous: + await self._report_locked(True) + + async def clear_detail(self, *, current: Callable[[], bool]) -> None: + async with self._lock: + if current(): + self._ordinary = AgentActivityDetail(kind="reasoning") + self._manual_wait = False def defer_false_report(self) -> None: """Start ``working: false`` and return without waiting for it. @@ -125,6 +197,9 @@ async def stop(self) -> None: def _arm_false_report(self) -> asyncio.Task[None] | None: self._active = False + self._cycle = None + self._ordinary = AgentActivityDetail(kind="reasoning") + self._manual_wait = False task, self._task = self._task, None if task is not None: task.cancel() @@ -151,7 +226,7 @@ async def _finish_false_send(self) -> None: if send.done(): self._false_send = None - async def _keep_alive(self) -> None: + async def _keep_alive(self, cycle: object) -> None: """Re-send ``working: true`` on cadence until stopped or capped.""" while True: await asyncio.sleep(self._keep_alive_seconds) @@ -167,7 +242,7 @@ async def _keep_alive(self) -> None: self._max_working_state_seconds, ) return - await self._safe_report(True) + await self._safe_report(True, cycle=cycle) def _cap_reached(self) -> bool: if self._max_working_state_seconds is None or self._started_at is None: @@ -175,14 +250,20 @@ def _cap_reached(self) -> bool: elapsed = asyncio.get_running_loop().time() - self._started_at return elapsed >= self._max_working_state_seconds - async def _safe_report(self, working: bool) -> None: - """Report under the serialization lock; never raise.""" + async def _safe_report(self, working: bool, *, cycle: object | None = None) -> None: async with self._lock: - try: - await self._report(working) - except asyncio.CancelledError: - raise - except Exception: - logger.exception( - "working-state report failed (working=%s); swallowing", working - ) + if working and ( + not self._active or self._cycle is not cycle or self._cap_reached() + ): + return + await self._report_locked(working) + + async def _report_locked(self, working: bool) -> None: + try: + await self._report(working) + except asyncio.CancelledError: + raise + except Exception: + logger.exception( + "working-state report failed (working=%s); swallowing", working + ) diff --git a/tests/platform/activity/conftest.py b/tests/platform/activity/conftest.py new file mode 100644 index 000000000..1deada2d2 --- /dev/null +++ b/tests/platform/activity/conftest.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest +from pytest_httpx import HTTPXMock + +import band.platform.message_lifecycle as lifecycle +from band.platform.activity import ActivitySupport +from band.platform.link import BandLink +from tests.platform.activity.kit import VERSION_URL, ActivityPeer +from tests.runtime.helpers import rest_client_over + + +@pytest.fixture +def support(monkeypatch: pytest.MonkeyPatch) -> ActivitySupport: + support = ActivitySupport() + monkeypatch.setattr(lifecycle, "activity_support", support) + return support + + +@pytest.fixture +async def peer(httpx_mock: HTTPXMock, support: ActivitySupport) -> ActivityPeer: + peer = ActivityPeer() + httpx_mock.add_callback( + peer.discover, url=VERSION_URL, is_optional=True, is_reusable=True + ) + return peer + + +@pytest.fixture +async def link(peer: ActivityPeer) -> AsyncIterator[BandLink]: + async with rest_client_over(peer.respond) as rest: + link = BandLink( + agent_id="agent", api_key="test-key", rest_url="https://platform.test" + ) + link.rest = rest + yield link diff --git a/tests/platform/activity/kit.py b/tests/platform/activity/kit.py new file mode 100644 index 000000000..d282ae3a5 --- /dev/null +++ b/tests/platform/activity/kit.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import asyncio +import json +from collections import deque +from typing import Any + +import httpx + +VERSION_URL = "https://platform.test/api/version" + + +class ActivityPeer: + def __init__(self) -> None: + self.version: object = {"agent_version": "1.25.0"} + self.version_status = 200 + self.version_content: bytes | None = None + self.version_requests: list[httpx.Request] = [] + self.reports: list[dict[str, Any]] = [] + self.responses: deque[httpx.Response] = deque() + self.post_entered = asyncio.Event() + self.post_release = asyncio.Event() + self.hold_post_number: int | None = None + self.discovering = asyncio.Event() + self.discovery_release: asyncio.Event | None = None + + async def discover(self, request: httpx.Request) -> httpx.Response: + self.version_requests.append(request) + self.discovering.set() + if self.discovery_release is not None: + await self.discovery_release.wait() + if self.version_content is not None: + return httpx.Response(self.version_status, content=self.version_content) + return httpx.Response(self.version_status, json=self.version) + + async def respond(self, request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + self.reports.append(body) + if len(self.reports) == self.hold_post_number: + self.post_entered.set() + await self.post_release.wait() + if self.responses: + return self.responses.popleft() + return httpx.Response(200, json={"data": {"working": body["working"]}}) diff --git a/tests/platform/activity/test_coordination.py b/tests/platform/activity/test_coordination.py new file mode 100644 index 000000000..716f52a2a --- /dev/null +++ b/tests/platform/activity/test_coordination.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import asyncio + +import pytest +from band_rest import AgentActivityDetail + +from band.platform.activity import ActivitySupport +from band.platform.link import BandLink +from tests.platform.activity.kit import VERSION_URL, ActivityPeer + + +class TestActivityCoordination: + async def test_concurrent_links_share_discovery_and_waiter_cancellation_is_independent( + self, link: BandLink, peer: ActivityPeer + ) -> None: + peer.discovery_release = asyncio.Event() + detail = AgentActivityDetail(kind="reasoning") + owner = asyncio.create_task( + link.report_activity("first", True, activity=detail) + ) + await peer.discovering.wait() + other = BandLink( + agent_id="second", api_key="test", rest_url="https://platform.test" + ) + other.rest = link.rest + waiter = asyncio.create_task( + other.report_activity("second", True, activity=detail) + ) + await asyncio.sleep(0) + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + assert not owner.done() + peer.discovery_release.set() + assert await owner + assert await other.report_activity("second", True, activity=detail) + assert len(peer.version_requests) == 1 + assert ( + peer.reports == [{"working": True, "activity": {"kind": "reasoning"}}] * 2 + ) + + async def test_discovery_owner_cancellation_unblocks_future_callers_as_boolean( + self, link: BandLink, peer: ActivityPeer + ) -> None: + peer.discovery_release = asyncio.Event() + detail = AgentActivityDetail(kind="reasoning") + owner = asyncio.create_task( + link.report_activity("first", True, activity=detail) + ) + await peer.discovering.wait() + owner.cancel() + with pytest.raises(asyncio.CancelledError): + await owner + assert await link.report_activity("next", True, activity=detail) + assert peer.reports == [{"working": True}] + assert len(peer.version_requests) == 1 + + def test_completed_negotiation_survives_later_event_loops( + self, support: ActivitySupport, peer: ActivityPeer + ) -> None: + assert asyncio.run(support.allows(VERSION_URL.removesuffix("/api/version"), 1)) + assert asyncio.run(support.allows("https://unqueried.test", 1)) + assert len(peer.version_requests) == 1 + + @pytest.mark.parametrize("failure", ["server", "malformed"]) + async def test_discovery_failure_is_cached_without_harming_boolean_reports( + self, link: BandLink, peer: ActivityPeer, failure: str + ) -> None: + if failure == "server": + peer.version_status = 500 + else: + peer.version_content = b"not-json" + for room in ("first", "second"): + assert await link.report_activity( + room, True, activity=AgentActivityDetail(kind="memory") + ) + assert len(peer.version_requests) == 1 + assert peer.reports == [{"working": True}] * 2 diff --git a/tests/platform/activity/test_deadlines.py b/tests/platform/activity/test_deadlines.py new file mode 100644 index 000000000..420d66cfc --- /dev/null +++ b/tests/platform/activity/test_deadlines.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import asyncio + +import httpx +import pytest +from band_rest import AgentActivityDetail + +from band.platform.link import BandLink +from tests.platform.activity.kit import ActivityPeer + + +@pytest.mark.looptime +class TestActivityDeadline: + async def test_stalled_discovery_reserves_half_the_budget_for_boolean_post( + self, link: BandLink, peer: ActivityPeer + ) -> None: + peer.discovery_release = asyncio.Event() + started = asyncio.get_running_loop().time() + assert await link.report_activity( + "room", True, activity=AgentActivityDetail(kind="reasoning") + ) + elapsed = asyncio.get_running_loop().time() - started + assert elapsed <= 1.01 + assert peer.reports == [{"working": True}] + assert len(peer.version_requests) == 1 + + async def test_retry_cannot_extend_total_deadline_and_downgrade_survives_timeout( + self, link: BandLink, peer: ActivityPeer + ) -> None: + peer.responses.append(httpx.Response(422)) + peer.hold_post_number = 2 + detail = AgentActivityDetail(kind="tool_call", name="Bash") + started = asyncio.get_running_loop().time() + assert not await link.report_activity("room", True, activity=detail) + elapsed = asyncio.get_running_loop().time() - started + assert elapsed <= 2.01 + assert peer.reports == [ + {"working": True, "activity": {"kind": "tool_call", "name": "Bash"}}, + {"working": True}, + ] + peer.hold_post_number = None + assert await link.report_activity("next", True, activity=detail) + assert peer.reports[-1] == {"working": True} diff --git a/tests/platform/activity/test_fallback.py b/tests/platform/activity/test_fallback.py new file mode 100644 index 000000000..f48b2efb3 --- /dev/null +++ b/tests/platform/activity/test_fallback.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from unittest.mock import Mock + +import httpx +import pytest +from band_rest import AgentActivityDetail + +from band.platform.link import BandLink +from tests.platform.activity.kit import ActivityPeer + + +class TestActivityFallback: + @pytest.mark.parametrize( + "body", [b'{"errors":{"activity":["unknown"]}}', b"not json", b""] + ) + async def test_422_retries_without_detail_and_latches_across_links( + self, link: BandLink, peer: ActivityPeer, body: bytes + ) -> None: + peer.responses.append(httpx.Response(422, content=body)) + detail = AgentActivityDetail(kind="tool_call", name="Read") + assert await link.report_activity("first", True, activity=detail) + other = BandLink( + agent_id="other-agent", api_key="test-key", rest_url="https://other.test" + ) + other.rest = link.rest + assert await other.report_activity("second", True, activity=detail) + assert peer.reports == [ + {"working": True, "activity": {"kind": "tool_call", "name": "Read"}}, + {"working": True}, + {"working": True}, + ] + assert len(peer.version_requests) == 1 + + @pytest.mark.parametrize("status", [400, 403, 500]) + async def test_other_errors_do_not_retry_or_disable( + self, link: BandLink, peer: ActivityPeer, status: int + ) -> None: + peer.responses.append(httpx.Response(status, json={"error": "failure"})) + detail = AgentActivityDetail(kind="reasoning") + assert not await link.report_activity("room", True, activity=detail) + assert await link.report_activity("room", True, activity=detail) + assert ( + peer.reports == [{"working": True, "activity": {"kind": "reasoning"}}] * 2 + ) + + async def test_fallback_echoed_false_keeps_stop_notification( + self, link: BandLink, peer: ActivityPeer + ) -> None: + peer.responses.extend( + [ + httpx.Response(422), + httpx.Response(200, json={"data": {"working": False}}), + ] + ) + stopped = Mock() + assert await link.report_activity( + "room", + True, + activity=AgentActivityDetail(kind="reasoning"), + on_stopped=stopped, + ) + stopped.assert_called_once_with() + assert peer.reports[-1] == {"working": True} diff --git a/tests/platform/activity/test_versions.py b/tests/platform/activity/test_versions.py new file mode 100644 index 000000000..a3e8e695b --- /dev/null +++ b/tests/platform/activity/test_versions.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import pytest +from band_rest import AgentActivityDetail + +from band.platform.link import BandLink +from tests.platform.activity.kit import ActivityPeer + + +class TestActivityVersion: + @pytest.mark.parametrize( + ("version", "supported"), + [ + ({"agent_version": "1.24.99"}, False), + ({"agent_version": "1.25.0"}, True), + ({"agent_version": "1.26.10"}, True), + ({"agent_version": "2.0.0"}, True), + ({"platform_version": "999.0.0"}, False), + ({"agent_version": "1.25.0-beta"}, False), + ({"agent_version": "broken"}, False), + ({"agent_version": "1.25"}, False), + ({"agent_version": None}, False), + ([], False), + ], + ) + async def test_agent_version_controls_real_request_detail( + self, link: BandLink, peer: ActivityPeer, version: object, supported: bool + ) -> None: + peer.version = version + detail = AgentActivityDetail(kind="tool_call", name="Bash") + assert await link.report_activity("room", True, activity=detail) + assert await link.report_activity("other", True, activity=detail) + assert len(peer.version_requests) == 1 + expected = {"working": True} + if supported: + expected["activity"] = {"kind": "tool_call", "name": "Bash"} + assert peer.reports == [expected, expected] + assert "authorization" not in peer.version_requests[0].headers + + @pytest.mark.parametrize("kind", ["reasoning", "memory", "permission"]) + async def test_non_tool_detail_omits_name_and_false_omits_detail( + self, link: BandLink, peer: ActivityPeer, kind: str + ) -> None: + detail = AgentActivityDetail(kind=kind) + assert await link.report_activity("room", True, activity=detail) + assert await link.report_activity("room", False, activity=detail) + assert peer.reports == [ + {"working": True, "activity": {"kind": kind}}, + {"working": False}, + ] + + async def test_boolean_callers_do_not_discover( + self, link: BandLink, peer: ActivityPeer + ) -> None: + assert await link.report_activity("room", True) + assert await link.report_activity("room", False) + assert not peer.version_requests + assert peer.reports == [{"working": True}, {"working": False}] diff --git a/tests/runtime/activityownership/conftest.py b/tests/runtime/activityownership/conftest.py index 16bcf6bfc..7fa9fec6e 100644 --- a/tests/runtime/activityownership/conftest.py +++ b/tests/runtime/activityownership/conftest.py @@ -4,7 +4,10 @@ from unittest.mock import AsyncMock import pytest +from pytest_httpx import HTTPXMock +import band.platform.message_lifecycle as lifecycle +from band.platform.activity import ActivitySupport from band.platform.event import PlatformEvent from band.platform.link import BandLink from band.runtime.execution import ExecutionContext @@ -14,25 +17,33 @@ from tests.runtime.helpers import ( AGENT_ID, ROOM_ID, - LifecyclePlatform, rest_client_over, ) @pytest.fixture -async def activity_platform() -> AsyncIterator[LifecyclePlatform]: +async def activity_platform( + monkeypatch: pytest.MonkeyPatch, httpx_mock: HTTPXMock +) -> AsyncIterator[ActivityPlatform]: + monkeypatch.setattr(lifecycle, "activity_support", ActivitySupport()) + httpx_mock.add_response( + url="https://app.band.ai/api/version", + json={"agent_version": "1.25.0"}, + is_optional=True, + ) yield ActivityPlatform() @pytest.fixture async def execution( - activity_platform: LifecyclePlatform, + activity_platform: ActivityPlatform, ) -> AsyncIterator[Callable[..., ExecutionContext]]: contexts: list[ExecutionContext] = [] - async with rest_client_over(activity_platform.answer) as rest: + async with rest_client_over(activity_platform.respond) as rest: def create( handler: Callable[[ExecutionContext, PlatformEvent], Awaitable[None]], + hub_room_id: str | None = None, **settings: object, ) -> ExecutionContext: link = BandLink(agent_id=AGENT_ID, api_key="test-key") @@ -45,6 +56,7 @@ def create( link, handler, agent_id=AGENT_ID, + hub_room_id=hub_room_id, on_platform_stop=runtime._on_platform_stop, config=SessionConfig(enable_context_hydration=False, **settings), ) @@ -53,4 +65,5 @@ def create( yield create for ctx in contexts: + activity_platform.activity_release.set() await ctx.stop() diff --git a/tests/runtime/activityownership/kit.py b/tests/runtime/activityownership/kit.py index d43a5b921..bfec560b1 100644 --- a/tests/runtime/activityownership/kit.py +++ b/tests/runtime/activityownership/kit.py @@ -1,13 +1,34 @@ from __future__ import annotations +import asyncio +import json +from typing import Any + import httpx from tests.runtime.helpers import ALICE, LifecyclePlatform, page class ActivityPlatform(LifecyclePlatform): + def __init__(self) -> None: + super().__init__() + self.activity_bodies: list[dict[str, Any]] = [] + self.hold_next_activity = False + self.activity_entered = asyncio.Event() + self.activity_release = asyncio.Event() + + async def respond(self, request: httpx.Request) -> httpx.Response: + response = self.answer(request) + if self.hold_next_activity and request.url.path.endswith("/activity"): + self.hold_next_activity = False + self.activity_entered.set() + await self.activity_release.wait() + return response + def answer(self, request: httpx.Request) -> httpx.Response: tail = request.url.path.rsplit("/", 1)[-1] + if tail == "activity": + self.activity_bodies.append(json.loads(request.content)) if tail == "participants": return page([{**ALICE, "role": "member", "status": "active"}]) if request.method == "POST" and tail == "messages": diff --git a/tests/runtime/activityownership/test_detail.py b/tests/runtime/activityownership/test_detail.py new file mode 100644 index 000000000..6383776de --- /dev/null +++ b/tests/runtime/activityownership/test_detail.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +import pytest +from pytest_httpx import HTTPXMock + +from band.core.turn_lifecycle import TurnTask, bind_turn_task +from band.platform.event import PlatformEvent +from band.runtime.execution import ExecutionContext +from band.runtime.tools.agent import AgentTools +from band.runtime.working_state import ( + end_tool_activity, + report_memory_activity, + report_tool_activity, + set_permission_wait, +) +from tests.conftest import make_message_event +from tests.runtime.activityownership.kit import ActivityPlatform +from tests.runtime.helpers import ROOM_ID + + +class TestOwnedDetail: + async def test_real_tools_observer_reports_canonical_wire_detail( + self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform + ) -> None: + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + await report_tool_activity(tools, "Bash") + await set_permission_wait(tools, True) + await end_tool_activity(tools) + await report_memory_activity(tools) + await set_permission_wait(tools, False) + await end_tool_activity(tools) + + ctx = execution(handler) + await ctx._process_event(make_message_event()) + assert activity_platform.activity_bodies == [ + {"working": True, "activity": {"kind": "reasoning"}}, + {"working": True, "activity": {"kind": "tool_call", "name": "Bash"}}, + {"working": True, "activity": {"kind": "permission"}}, + {"working": True, "activity": {"kind": "memory"}}, + {"working": True, "activity": {"kind": "reasoning"}}, + {"working": False}, + ] + + @pytest.mark.looptime + async def test_settled_owner_remains_authoritative_and_transient_tools_are_quiet( + self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform + ) -> None: + release = asyncio.Event() + captured: list[AgentTools] = [] + workers: list[TurnTask] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + if captured: + await report_tool_activity(tools, "unrelated") + return + captured.append(tools) + workers.append(bind_turn_task(tools, asyncio.create_task(release.wait()))) + await set_permission_wait(tools, True) + + ctx = execution(handler) + await ctx._process_event(make_message_event(msg_id="original")) + await ctx._process_event(make_message_event(msg_id="busy")) + await asyncio.sleep(6.1) + assert ( + activity_platform.activity_bodies[-2:] + == [{"working": True, "activity": {"kind": "permission"}}] * 2 + ) + await report_tool_activity(captured[0], "Bash") + await set_permission_wait(captured[0], False) + assert activity_platform.activity_bodies[-1] == { + "working": True, + "activity": {"kind": "tool_call", "name": "Bash"}, + } + release.set() + await workers[0].task + await asyncio.sleep(0) + await ctx._join_activity_completion() + assert activity_platform.activity_bodies[-1] == {"working": False} + before = list(activity_platform.activity_bodies) + await end_tool_activity(captured[0]) + assert activity_platform.activity_bodies == before + + async def test_queued_observation_is_rechecked_after_stop_and_replacement( + self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform + ) -> None: + captured: list[AgentTools] = [] + release = asyncio.Event() + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + captured.append(AgentTools.from_context(ctx)) + await release.wait() + + ctx = execution(handler) + turn = asyncio.create_task(ctx._process_event(make_message_event(msg_id="old"))) + while not captured: + await asyncio.sleep(0) + activity_platform.hold_next_activity = True + change = asyncio.create_task(report_tool_activity(captured[0], "Bash")) + await activity_platform.activity_entered.wait() + stale = asyncio.create_task(report_memory_activity(captured[0])) + ctx.interrupt() + activity_platform.activity_release.set() + await asyncio.gather(turn, change, stale, return_exceptions=True) + release.set() + await ctx._process_event(make_message_event(msg_id="new")) + assert activity_platform.activity_bodies[-3:] == [ + {"working": False}, + {"working": True, "activity": {"kind": "reasoning"}}, + {"working": False}, + ] + assert not any( + body.get("activity", {}).get("kind") == "memory" + for body in activity_platform.activity_bodies + ) + + @pytest.mark.parametrize("excluded", ["disabled", "hub"]) + async def test_excluded_reporting_ignores_observations_without_discovery( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + httpx_mock: HTTPXMock, + excluded: str, + ) -> None: + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + await report_tool_activity(tools, "Bash") + await set_permission_wait(tools, True) + await report_memory_activity(tools) + await end_tool_activity(tools) + + settings = ( + {"enable_working_state": False} + if excluded == "disabled" + else {"hub_room_id": ROOM_ID} + ) + ctx = execution(handler, **settings) + await ctx._process_event(make_message_event()) + assert not activity_platform.activity_bodies + assert not httpx_mock.get_requests() diff --git a/tests/runtime/conftest.py b/tests/runtime/conftest.py index 46f8a06ed..0a26403e8 100644 --- a/tests/runtime/conftest.py +++ b/tests/runtime/conftest.py @@ -32,7 +32,9 @@ ListAgentChatParticipantsResponse, ) +import band.platform.message_lifecycle as lifecycle from band.core.types import PlatformConnection, PlatformMessage +from band.platform.activity import ActivitySupport from band.runtime.presence import RoomPresence from tests.conftest import make_participant_mock from tests.runtime.helpers import AGENT_ID @@ -259,3 +261,15 @@ def link(mock_rest_client: MagicMock) -> MagicMock: link.get_stale_processing_messages = AsyncMock(return_value=[]) link.report_activity = AsyncMock(return_value=True) return link + + +@pytest.fixture(autouse=True) +def runtime_activity_support(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep deployment discovery independent of runtime scheduling regressions.""" + support = ActivitySupport() + + async def discover(rest_url: str, budget: float) -> bool: + return True + + monkeypatch.setattr(support, "_discover", discover) + monkeypatch.setattr(lifecycle, "activity_support", support) diff --git a/tests/runtime/workingstate/test_detail.py b/tests/runtime/workingstate/test_detail.py new file mode 100644 index 000000000..4d776d1fb --- /dev/null +++ b/tests/runtime/workingstate/test_detail.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest +from band_rest import AgentActivityDetail + +from band.runtime.working_state import WorkingStateReporter + + +@pytest.mark.looptime +class TestActivityDetail: + async def test_changes_repeat_current_detail_and_keep_boolean_callback( + self, + ) -> None: + reports: list[tuple[bool, dict[str, Any] | None]] = [] + + async def report(working: bool) -> None: + reports.append( + ( + working, + reporter.activity.model_dump(exclude_unset=True) + if working + else None, + ) + ) + + reporter = WorkingStateReporter(report) + try: + await reporter.start() + await reporter.update( + current=lambda: True, + activity=AgentActivityDetail( + kind="tool_call", name="Bash", arguments="private" + ), + ) + await reporter.update( + current=lambda: True, + activity=AgentActivityDetail(kind="tool_call", name="Bash"), + ) + await asyncio.sleep(3.1) + await reporter.update( + current=lambda: True, + activity=AgentActivityDetail( + kind="reasoning", name="private", output="private" + ), + ) + finally: + await reporter.stop() + assert reports == [ + (True, {"kind": "reasoning"}), + (True, {"kind": "tool_call", "name": "Bash"}), + (True, {"kind": "tool_call", "name": "Bash"}), + (True, {"kind": "reasoning"}), + (False, None), + ] + + async def test_manual_wait_overrides_latest_progress_without_restoring_stale_state( + self, + ) -> None: + reports: list[str] = [] + + async def report(working: bool) -> None: + if working: + reports.append(reporter.activity.kind) + + reporter = WorkingStateReporter(report) + try: + await reporter.start() + await reporter.update( + current=lambda: True, + activity=AgentActivityDetail(kind="tool_call", name="Read"), + ) + await reporter.update(current=lambda: True, waiting=True) + await reporter.update( + current=lambda: True, activity=AgentActivityDetail(kind="reasoning") + ) + await reporter.update(current=lambda: True, waiting=True) + await reporter.update( + current=lambda: True, activity=AgentActivityDetail(kind="memory") + ) + await reporter.update(current=lambda: True, waiting=False) + finally: + await reporter.stop() + assert reports == ["reasoning", "tool_call", "permission", "memory"] + + async def test_cap_prevents_detail_from_reviving_the_indicator(self) -> None: + reports: list[bool] = [] + + async def report(working: bool) -> None: + reports.append(working) + + reporter = WorkingStateReporter(report, max_working_state_seconds=2) + try: + await reporter.start() + await asyncio.sleep(2.1) + await reporter.update( + current=lambda: True, + activity=AgentActivityDetail(kind="tool_call", name="Bash"), + ) + await asyncio.sleep(3) + assert reports == [True] + finally: + await reporter.stop() + assert reports == [True, False] From d76f57131f1981579a6f9c7e74217112d3d9a5e6 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 18 Oct 2026 11:11:58 +0300 Subject: [PATCH 07/33] style: Format detail ownership fixture parameters --- tests/runtime/activityownership/test_detail.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/runtime/activityownership/test_detail.py b/tests/runtime/activityownership/test_detail.py index 6383776de..83e7bdf77 100644 --- a/tests/runtime/activityownership/test_detail.py +++ b/tests/runtime/activityownership/test_detail.py @@ -23,7 +23,9 @@ class TestOwnedDetail: async def test_real_tools_observer_reports_canonical_wire_detail( - self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, ) -> None: async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: tools = AgentTools.from_context(ctx) @@ -47,7 +49,9 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: @pytest.mark.looptime async def test_settled_owner_remains_authoritative_and_transient_tools_are_quiet( - self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, ) -> None: release = asyncio.Event() captured: list[AgentTools] = [] @@ -86,7 +90,9 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: assert activity_platform.activity_bodies == before async def test_queued_observation_is_rechecked_after_stop_and_replacement( - self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, ) -> None: captured: list[AgentTools] = [] release = asyncio.Event() From 184263862293e389d75afcf734471af58a92f8d2 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 18 Oct 2026 11:41:21 +0300 Subject: [PATCH 08/33] test: Avoid reserved peer fixtures in activity wire tests --- tests/platform/activity/conftest.py | 14 ++++---- tests/platform/activity/test_coordination.py | 37 ++++++++++---------- tests/platform/activity/test_deadlines.py | 20 +++++------ tests/platform/activity/test_fallback.py | 23 ++++++------ tests/platform/activity/test_versions.py | 24 +++++++------ 5 files changed, 64 insertions(+), 54 deletions(-) diff --git a/tests/platform/activity/conftest.py b/tests/platform/activity/conftest.py index 1deada2d2..7734dfc78 100644 --- a/tests/platform/activity/conftest.py +++ b/tests/platform/activity/conftest.py @@ -20,17 +20,19 @@ def support(monkeypatch: pytest.MonkeyPatch) -> ActivitySupport: @pytest.fixture -async def peer(httpx_mock: HTTPXMock, support: ActivitySupport) -> ActivityPeer: - peer = ActivityPeer() +async def activity_peer( + httpx_mock: HTTPXMock, support: ActivitySupport +) -> ActivityPeer: + activity_peer = ActivityPeer() httpx_mock.add_callback( - peer.discover, url=VERSION_URL, is_optional=True, is_reusable=True + activity_peer.discover, url=VERSION_URL, is_optional=True, is_reusable=True ) - return peer + return activity_peer @pytest.fixture -async def link(peer: ActivityPeer) -> AsyncIterator[BandLink]: - async with rest_client_over(peer.respond) as rest: +async def link(activity_peer: ActivityPeer) -> AsyncIterator[BandLink]: + async with rest_client_over(activity_peer.respond) as rest: link = BandLink( agent_id="agent", api_key="test-key", rest_url="https://platform.test" ) diff --git a/tests/platform/activity/test_coordination.py b/tests/platform/activity/test_coordination.py index 716f52a2a..060bfdd74 100644 --- a/tests/platform/activity/test_coordination.py +++ b/tests/platform/activity/test_coordination.py @@ -12,14 +12,14 @@ class TestActivityCoordination: async def test_concurrent_links_share_discovery_and_waiter_cancellation_is_independent( - self, link: BandLink, peer: ActivityPeer + self, link: BandLink, activity_peer: ActivityPeer ) -> None: - peer.discovery_release = asyncio.Event() + activity_peer.discovery_release = asyncio.Event() detail = AgentActivityDetail(kind="reasoning") owner = asyncio.create_task( link.report_activity("first", True, activity=detail) ) - await peer.discovering.wait() + await activity_peer.discovering.wait() other = BandLink( agent_id="second", api_key="test", rest_url="https://platform.test" ) @@ -32,48 +32,49 @@ async def test_concurrent_links_share_discovery_and_waiter_cancellation_is_indep with pytest.raises(asyncio.CancelledError): await waiter assert not owner.done() - peer.discovery_release.set() + activity_peer.discovery_release.set() assert await owner assert await other.report_activity("second", True, activity=detail) - assert len(peer.version_requests) == 1 + assert len(activity_peer.version_requests) == 1 assert ( - peer.reports == [{"working": True, "activity": {"kind": "reasoning"}}] * 2 + activity_peer.reports + == [{"working": True, "activity": {"kind": "reasoning"}}] * 2 ) async def test_discovery_owner_cancellation_unblocks_future_callers_as_boolean( - self, link: BandLink, peer: ActivityPeer + self, link: BandLink, activity_peer: ActivityPeer ) -> None: - peer.discovery_release = asyncio.Event() + activity_peer.discovery_release = asyncio.Event() detail = AgentActivityDetail(kind="reasoning") owner = asyncio.create_task( link.report_activity("first", True, activity=detail) ) - await peer.discovering.wait() + await activity_peer.discovering.wait() owner.cancel() with pytest.raises(asyncio.CancelledError): await owner assert await link.report_activity("next", True, activity=detail) - assert peer.reports == [{"working": True}] - assert len(peer.version_requests) == 1 + assert activity_peer.reports == [{"working": True}] + assert len(activity_peer.version_requests) == 1 def test_completed_negotiation_survives_later_event_loops( - self, support: ActivitySupport, peer: ActivityPeer + self, support: ActivitySupport, activity_peer: ActivityPeer ) -> None: assert asyncio.run(support.allows(VERSION_URL.removesuffix("/api/version"), 1)) assert asyncio.run(support.allows("https://unqueried.test", 1)) - assert len(peer.version_requests) == 1 + assert len(activity_peer.version_requests) == 1 @pytest.mark.parametrize("failure", ["server", "malformed"]) async def test_discovery_failure_is_cached_without_harming_boolean_reports( - self, link: BandLink, peer: ActivityPeer, failure: str + self, link: BandLink, activity_peer: ActivityPeer, failure: str ) -> None: if failure == "server": - peer.version_status = 500 + activity_peer.version_status = 500 else: - peer.version_content = b"not-json" + activity_peer.version_content = b"not-json" for room in ("first", "second"): assert await link.report_activity( room, True, activity=AgentActivityDetail(kind="memory") ) - assert len(peer.version_requests) == 1 - assert peer.reports == [{"working": True}] * 2 + assert len(activity_peer.version_requests) == 1 + assert activity_peer.reports == [{"working": True}] * 2 diff --git a/tests/platform/activity/test_deadlines.py b/tests/platform/activity/test_deadlines.py index 420d66cfc..aec390434 100644 --- a/tests/platform/activity/test_deadlines.py +++ b/tests/platform/activity/test_deadlines.py @@ -13,32 +13,32 @@ @pytest.mark.looptime class TestActivityDeadline: async def test_stalled_discovery_reserves_half_the_budget_for_boolean_post( - self, link: BandLink, peer: ActivityPeer + self, link: BandLink, activity_peer: ActivityPeer ) -> None: - peer.discovery_release = asyncio.Event() + activity_peer.discovery_release = asyncio.Event() started = asyncio.get_running_loop().time() assert await link.report_activity( "room", True, activity=AgentActivityDetail(kind="reasoning") ) elapsed = asyncio.get_running_loop().time() - started assert elapsed <= 1.01 - assert peer.reports == [{"working": True}] - assert len(peer.version_requests) == 1 + assert activity_peer.reports == [{"working": True}] + assert len(activity_peer.version_requests) == 1 async def test_retry_cannot_extend_total_deadline_and_downgrade_survives_timeout( - self, link: BandLink, peer: ActivityPeer + self, link: BandLink, activity_peer: ActivityPeer ) -> None: - peer.responses.append(httpx.Response(422)) - peer.hold_post_number = 2 + activity_peer.responses.append(httpx.Response(422)) + activity_peer.hold_post_number = 2 detail = AgentActivityDetail(kind="tool_call", name="Bash") started = asyncio.get_running_loop().time() assert not await link.report_activity("room", True, activity=detail) elapsed = asyncio.get_running_loop().time() - started assert elapsed <= 2.01 - assert peer.reports == [ + assert activity_peer.reports == [ {"working": True, "activity": {"kind": "tool_call", "name": "Bash"}}, {"working": True}, ] - peer.hold_post_number = None + activity_peer.hold_post_number = None assert await link.report_activity("next", True, activity=detail) - assert peer.reports[-1] == {"working": True} + assert activity_peer.reports[-1] == {"working": True} diff --git a/tests/platform/activity/test_fallback.py b/tests/platform/activity/test_fallback.py index f48b2efb3..c88eac256 100644 --- a/tests/platform/activity/test_fallback.py +++ b/tests/platform/activity/test_fallback.py @@ -15,9 +15,9 @@ class TestActivityFallback: "body", [b'{"errors":{"activity":["unknown"]}}', b"not json", b""] ) async def test_422_retries_without_detail_and_latches_across_links( - self, link: BandLink, peer: ActivityPeer, body: bytes + self, link: BandLink, activity_peer: ActivityPeer, body: bytes ) -> None: - peer.responses.append(httpx.Response(422, content=body)) + activity_peer.responses.append(httpx.Response(422, content=body)) detail = AgentActivityDetail(kind="tool_call", name="Read") assert await link.report_activity("first", True, activity=detail) other = BandLink( @@ -25,29 +25,32 @@ async def test_422_retries_without_detail_and_latches_across_links( ) other.rest = link.rest assert await other.report_activity("second", True, activity=detail) - assert peer.reports == [ + assert activity_peer.reports == [ {"working": True, "activity": {"kind": "tool_call", "name": "Read"}}, {"working": True}, {"working": True}, ] - assert len(peer.version_requests) == 1 + assert len(activity_peer.version_requests) == 1 @pytest.mark.parametrize("status", [400, 403, 500]) async def test_other_errors_do_not_retry_or_disable( - self, link: BandLink, peer: ActivityPeer, status: int + self, link: BandLink, activity_peer: ActivityPeer, status: int ) -> None: - peer.responses.append(httpx.Response(status, json={"error": "failure"})) + activity_peer.responses.append( + httpx.Response(status, json={"error": "failure"}) + ) detail = AgentActivityDetail(kind="reasoning") assert not await link.report_activity("room", True, activity=detail) assert await link.report_activity("room", True, activity=detail) assert ( - peer.reports == [{"working": True, "activity": {"kind": "reasoning"}}] * 2 + activity_peer.reports + == [{"working": True, "activity": {"kind": "reasoning"}}] * 2 ) async def test_fallback_echoed_false_keeps_stop_notification( - self, link: BandLink, peer: ActivityPeer + self, link: BandLink, activity_peer: ActivityPeer ) -> None: - peer.responses.extend( + activity_peer.responses.extend( [ httpx.Response(422), httpx.Response(200, json={"data": {"working": False}}), @@ -61,4 +64,4 @@ async def test_fallback_echoed_false_keeps_stop_notification( on_stopped=stopped, ) stopped.assert_called_once_with() - assert peer.reports[-1] == {"working": True} + assert activity_peer.reports[-1] == {"working": True} diff --git a/tests/platform/activity/test_versions.py b/tests/platform/activity/test_versions.py index a3e8e695b..1eb74f30e 100644 --- a/tests/platform/activity/test_versions.py +++ b/tests/platform/activity/test_versions.py @@ -24,35 +24,39 @@ class TestActivityVersion: ], ) async def test_agent_version_controls_real_request_detail( - self, link: BandLink, peer: ActivityPeer, version: object, supported: bool + self, + link: BandLink, + activity_peer: ActivityPeer, + version: object, + supported: bool, ) -> None: - peer.version = version + activity_peer.version = version detail = AgentActivityDetail(kind="tool_call", name="Bash") assert await link.report_activity("room", True, activity=detail) assert await link.report_activity("other", True, activity=detail) - assert len(peer.version_requests) == 1 + assert len(activity_peer.version_requests) == 1 expected = {"working": True} if supported: expected["activity"] = {"kind": "tool_call", "name": "Bash"} - assert peer.reports == [expected, expected] - assert "authorization" not in peer.version_requests[0].headers + assert activity_peer.reports == [expected, expected] + assert "authorization" not in activity_peer.version_requests[0].headers @pytest.mark.parametrize("kind", ["reasoning", "memory", "permission"]) async def test_non_tool_detail_omits_name_and_false_omits_detail( - self, link: BandLink, peer: ActivityPeer, kind: str + self, link: BandLink, activity_peer: ActivityPeer, kind: str ) -> None: detail = AgentActivityDetail(kind=kind) assert await link.report_activity("room", True, activity=detail) assert await link.report_activity("room", False, activity=detail) - assert peer.reports == [ + assert activity_peer.reports == [ {"working": True, "activity": {"kind": kind}}, {"working": False}, ] async def test_boolean_callers_do_not_discover( - self, link: BandLink, peer: ActivityPeer + self, link: BandLink, activity_peer: ActivityPeer ) -> None: assert await link.report_activity("room", True) assert await link.report_activity("room", False) - assert not peer.version_requests - assert peer.reports == [{"working": True}, {"working": False}] + assert not activity_peer.version_requests + assert activity_peer.reports == [{"working": True}, {"working": False}] From 06d8124481475460782840b8854285cf7c0a2d92 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 18 Oct 2026 14:28:32 +0300 Subject: [PATCH 09/33] test: Preserve backlog ordering across valid recovery retries --- tests/runtime/test_execution.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/runtime/test_execution.py b/tests/runtime/test_execution.py index 8fdb098d5..b72e049f8 100644 --- a/tests/runtime/test_execution.py +++ b/tests/runtime/test_execution.py @@ -6,7 +6,7 @@ import logging from datetime import UTC, datetime, timedelta from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, call import pytest from band_sdk_core import AgentFailure, ClaimRegistry, RetryTracker @@ -1645,9 +1645,14 @@ async def test_refused_claims_never_spend_the_retry_budget( mock_handler.assert_awaited_once() assert not ctx._retry_tracker.is_permanently_failed(msg.id) + @pytest.mark.parametrize("initial_recovery_delay", [0.0, 10.0]) async def test_startup_backlog_claim_failure_does_not_process_newer_ws_event( - self, mock_link_with_next, mock_handler - ): + self, + mock_link_with_next, + mock_handler, + monkeypatch: pytest.MonkeyPatch, + initial_recovery_delay: float, + ) -> None: """Startup sync should not switch to WebSocket after an unclaimable backlog message.""" backlog_msg = PlatformMessage( @@ -1673,6 +1678,11 @@ async def test_startup_backlog_claim_failure_does_not_process_newer_ws_event( ), ) + monkeypatch.setattr( + "band.runtime.resync.random.uniform", + lambda lower, upper: initial_recovery_delay, + ) + await ctx.on_event( make_message_event(room_id="room-123", msg_id="msg-newer-ws") ) @@ -1681,8 +1691,10 @@ async def test_startup_backlog_claim_failure_does_not_process_newer_ws_event( lambda: mock_link_with_next.mark_processing.await_count >= 1 ) - mock_link_with_next.mark_processing.assert_awaited_once_with( - "room-123", "msg-older-claim-fails" + assert ( + mock_link_with_next.mark_processing.await_args_list + == [call("room-123", "msg-older-claim-fails")] + * mock_link_with_next.mark_processing.await_count ) mock_handler.assert_not_called() assert ctx._sync_complete is False From 6a62878a9274c56e3c82a67a30702e4fc0fee20f Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 20 Oct 2026 15:53:31 +0300 Subject: [PATCH 10/33] refactor: Flatten the activity compatibility retry Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb --- src/band/platform/activity.py | 9 ++--- src/band/platform/message_lifecycle.py | 56 +++++++++++++++----------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/src/band/platform/activity.py b/src/band/platform/activity.py index beb48c74e..7a1888249 100644 --- a/src/band/platform/activity.py +++ b/src/band/platform/activity.py @@ -50,18 +50,15 @@ async def allows(self, rest_url: str, budget: float) -> bool: with self._lock: if self._disabled: return False - owner = self._discovery is None - if owner: - self._discovery = Future() discovery = self._discovery - assert discovery is not None + owner = discovery is None + if discovery is None: + discovery = self._discovery = Future() if owner: supported = False try: async with asyncio.timeout(budget): supported = await self._discover(rest_url, budget) - except asyncio.CancelledError: - raise except (httpx.HTTPError, ValueError, TimeoutError): pass finally: diff --git a/src/band/platform/message_lifecycle.py b/src/band/platform/message_lifecycle.py index 77487bf67..f4de247dd 100644 --- a/src/band/platform/message_lifecycle.py +++ b/src/band/platform/message_lifecycle.py @@ -380,33 +380,41 @@ async def _post_activity( working: bool, activity: AgentActivityDetail | None, deadline: float, + ) -> ReportAgentChatActivityResponse: + detail = activity if activity_support.supported else None + try: + return await MessageLifecycle._send_activity( + rest, room_id, working, detail, deadline + ) + except ApiError as error: + if detail is None or error.status_code != HTTPStatus.UNPROCESSABLE_ENTITY: + raise + activity_support.disable() + return await MessageLifecycle._send_activity( + rest, room_id, working, None, deadline + ) + + @staticmethod + async def _send_activity( + rest: AsyncRestClient, + room_id: str, + working: bool, + activity: AgentActivityDetail | None, + deadline: float, ) -> ReportAgentChatActivityResponse: kwargs: dict[str, Any] = {} - if activity is not None and activity_support.supported: + if activity is not None: kwargs["activity"] = activity - for attempt in range(2): - try: - return await rest.agent_api_activity.report_agent_chat_activity( - chat_id=room_id, - working=working, - request_options={ - **NO_RETRY_REQUEST_OPTIONS, - "timeout_in_seconds": max( - 1, ceil(deadline - asyncio.get_running_loop().time()) - ), - }, - **kwargs, - ) - except ApiError as error: - if ( - attempt - or "activity" not in kwargs - or error.status_code != HTTPStatus.UNPROCESSABLE_ENTITY - ): - raise - activity_support.disable() - kwargs.clear() - raise AssertionError("Activity compatibility retry did not return") + remaining = deadline - asyncio.get_running_loop().time() + return await rest.agent_api_activity.report_agent_chat_activity( + chat_id=room_id, + working=working, + request_options={ + **NO_RETRY_REQUEST_OPTIONS, + "timeout_in_seconds": max(1, ceil(remaining)), + }, + **kwargs, + ) async def get_next_message( self, rest: AsyncRestClient, room_id: str From 4a0404608f08dc750ef56c73ac00088de739f872 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 20 Oct 2026 17:25:56 +0300 Subject: [PATCH 11/33] fix: Learn activity detail support from response headers The SaaS default rest_url (app.band.ai) serves the frontend at /api/version, so discovery latched every default process to boolean reporting. Every agent API response already carries X-API-Version, so the activity POST itself now teaches the process whether detail is supported. This removes the discovery GET, its rest_url plumbing, the half-budget split and the cross-loop owner/waiter coordination. The 422 downgrade now logs the room and body, and an unadvertised version is logged at debug. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb --- docs/turn-outcome.md | 11 +- src/band/platform/activity.py | 88 +++++------- src/band/platform/link.py | 1 - src/band/platform/message_lifecycle.py | 66 ++++----- src/band/runtime/sessionconfig.py | 2 +- tests/platform/activity/conftest.py | 41 ++++-- tests/platform/activity/kit.py | 45 +++--- tests/platform/activity/test_coordination.py | 80 ----------- tests/platform/activity/test_deadlines.py | 32 ++--- tests/platform/activity/test_fallback.py | 59 +++++--- tests/platform/activity/test_reporting.py | 44 ++++++ tests/platform/activity/test_versions.py | 84 +++++++---- tests/platform/test_link.py | 139 ------------------- tests/runtime/activityownership/conftest.py | 15 +- tests/runtime/conftest.py | 11 +- 15 files changed, 271 insertions(+), 447 deletions(-) delete mode 100644 tests/platform/activity/test_coordination.py create mode 100644 tests/platform/activity/test_reporting.py diff --git a/docs/turn-outcome.md b/docs/turn-outcome.md index 04d574011..a665f07d0 100644 --- a/docs/turn-outcome.md +++ b/docs/turn-outcome.md @@ -430,8 +430,9 @@ detail, independently of chat narration and tool capabilities. Changes and keep-alives share one serialized reporter. The duration cap stops both kinds of refresh without cancelling provider work; actual completion still clears. -Detail compatibility is negotiated once per process using the first platform's -`agent_version`. Failed or unsupported discovery preserves boolean reporting. -A detail-bearing HTTP 422 permanently disables detail across all links in the -process and retries that report once without detail, within its original budget. -The version lookup uses at most half that budget, reserving time for liveness. +Detail compatibility is learned from the `X-API-Version` header the platform +puts on every activity response. Reports stay boolean until a response in the +process advertises agent API 1.25.0 or later; a missing or older version keeps +boolean reporting. A detail-bearing HTTP 422 permanently disables detail across +all links in the process and retries that report once without detail, within +its original deadline. diff --git a/src/band/platform/activity.py b/src/band/platform/activity.py index 7a1888249..aeff03672 100644 --- a/src/band/platform/activity.py +++ b/src/band/platform/activity.py @@ -1,92 +1,66 @@ """Process-wide negotiation for optional room activity detail. -The first endpoint defines support for the process. A detail rejection on any -endpoint permanently downgrades every link; boolean liveness remains available. +Every agent API response names its version in ``X-API-Version``. Detail is sent +once any activity response reported a supporting version. A detail rejection on +any link permanently downgrades every link; boolean liveness remains available. """ from __future__ import annotations -import asyncio import logging import re -from concurrent.futures import Future +from collections.abc import Mapping from threading import Lock -import httpx - logger = logging.getLogger(__name__) +# The first agent API that accepts ``activity``; older ones reject it with 422. MINIMUM_ACTIVITY_VERSION = (1, 25, 0) +API_VERSION_HEADER = "x-api-version" RELEASE_VERSION = re.compile(r"([0-9]+)\.([0-9]+)\.([0-9]+)") +def _supports_detail(version: str | None) -> bool: + match = RELEASE_VERSION.fullmatch(version) if version is not None else None + return ( + match is not None + and tuple(map(int, match.groups())) >= MINIMUM_ACTIVITY_VERSION + ) + + class ActivitySupport: - """Share discovery without retaining primitives owned by an asyncio loop.""" + """Learn detail support from response headers; a rejection latches it off.""" def __init__(self) -> None: self._lock = Lock() - self._discovery: Future[bool] | None = None + self._learned = False self._disabled = False @property def supported(self) -> bool: with self._lock: - return ( - not self._disabled - and self._discovery is not None - and self._discovery.done() - and self._discovery.result() + return self._learned and not self._disabled + + def observe(self, headers: Mapping[str, str]) -> None: + version = headers.get(API_VERSION_HEADER) + if not _supports_detail(version): + logger.debug( + "Agent API version %r does not advertise activity detail", version ) + return + with self._lock: + self._learned = True - def disable(self) -> None: + def disable(self, *, room_id: str, reason: object) -> None: with self._lock: already_disabled = self._disabled self._disabled = True if not already_disabled: logger.info( - "Activity detail rejected; using boolean reporting for this process" + "Activity detail rejected in room %s (%s); " + "using boolean reporting for this process", + room_id, + reason, ) - async def allows(self, rest_url: str, budget: float) -> bool: - with self._lock: - if self._disabled: - return False - discovery = self._discovery - owner = discovery is None - if discovery is None: - discovery = self._discovery = Future() - if owner: - supported = False - try: - async with asyncio.timeout(budget): - supported = await self._discover(rest_url, budget) - except (httpx.HTTPError, ValueError, TimeoutError): - pass - finally: - discovery.set_result(supported) - if not supported: - logger.info( - "Activity detail unavailable; using boolean reporting for this process" - ) - else: - try: - async with asyncio.timeout(budget): - await asyncio.shield(asyncio.wrap_future(discovery)) - except TimeoutError: - return False - return self.supported - - @staticmethod - async def _discover(rest_url: str, budget: float) -> bool: - async with httpx.AsyncClient(timeout=budget) as client: - response = await client.get(f"{rest_url.rstrip('/')}/api/version") - response.raise_for_status() - data = response.json() - version = data.get("agent_version") if isinstance(data, dict) else None - match = RELEASE_VERSION.fullmatch(version) if isinstance(version, str) else None - return ( - match is not None - and tuple(map(int, match.groups())) >= MINIMUM_ACTIVITY_VERSION - ) - activity_support = ActivitySupport() diff --git a/src/band/platform/link.py b/src/band/platform/link.py index a7434211b..f85634185 100644 --- a/src/band/platform/link.py +++ b/src/band/platform/link.py @@ -657,7 +657,6 @@ async def report_activity( room_id, working, activity=activity, - rest_url=self.rest_url, timeout_seconds=timeout_seconds, on_stopped=on_stopped, ) diff --git a/src/band/platform/message_lifecycle.py b/src/band/platform/message_lifecycle.py index f4de247dd..16b990ca2 100644 --- a/src/band/platform/message_lifecycle.py +++ b/src/band/platform/message_lifecycle.py @@ -13,7 +13,6 @@ from dataclasses import dataclass from datetime import UTC, datetime from http import HTTPStatus -from math import ceil from typing import Any, TypeVar from band_rest.agent_api_activity.types.report_agent_chat_activity_response import ( @@ -23,6 +22,7 @@ from band_rest.core.http_response import AsyncHttpResponse from band_rest.core.request_options import RequestOptions from band_rest.types.agent_activity_detail import AgentActivityDetail +from band_rest.types.agent_activity_response import AgentActivityResponse from band_rest.types.chat_message import ChatMessage from band_rest.types.error import Error as PlatformError from band_rest.types.validation_error import ValidationError as ErrorEnvelope @@ -314,7 +314,6 @@ async def report_activity( working: bool, *, activity: AgentActivityDetail | None = None, - rest_url: str | None = None, timeout_seconds: int = 2, on_stopped: Callable[[], None] | None = None, ) -> bool: @@ -327,9 +326,10 @@ async def report_activity( the backstop and activity reporting must never break message processing. - One deadline bounds discovery, POST and the sole compatibility retry. - A rejected detail permanently downgrades the process to boolean reports; - other failures wait for the next keep-alive or platform TTL. + Detail is attached once a response reported a supporting agent API + version. One deadline bounds the POST and its sole compatibility retry: + a rejected detail permanently downgrades the process to boolean + reports; other failures wait for the next keep-alive or platform TTL. A successful ``working=True`` report echoed as ``False`` identifies a stopped execution. Notify ``on_stopped`` separately from the request's @@ -337,17 +337,14 @@ async def report_activity( """ try: async with asyncio.timeout(timeout_seconds): - deadline = asyncio.get_running_loop().time() + timeout_seconds - detail = activity if working else None - if detail is not None and ( - rest_url is None - or not await activity_support.allows(rest_url, timeout_seconds / 2) - ): - detail = None response = await self._post_activity( - rest, room_id, working, detail, deadline + rest, + room_id, + working, + activity if working else None, + timeout_seconds, ) - stopped = working and response.data.working is False + stopped = working and response.working is False except Exception as e: # noqa: BLE001 -- best-effort event emission must not crash the turn/link if not self._activity_report_failing: self._activity_report_failing = True @@ -379,20 +376,22 @@ async def _post_activity( room_id: str, working: bool, activity: AgentActivityDetail | None, - deadline: float, - ) -> ReportAgentChatActivityResponse: + timeout_seconds: int, + ) -> AgentActivityResponse: detail = activity if activity_support.supported else None try: - return await MessageLifecycle._send_activity( - rest, room_id, working, detail, deadline + response = await MessageLifecycle._send_activity( + rest, room_id, working, detail, timeout_seconds ) except ApiError as error: if detail is None or error.status_code != HTTPStatus.UNPROCESSABLE_ENTITY: raise - activity_support.disable() - return await MessageLifecycle._send_activity( - rest, room_id, working, None, deadline - ) + activity_support.disable(room_id=room_id, reason=error.body) + response = await MessageLifecycle._send_activity( + rest, room_id, working, None, timeout_seconds + ) + activity_support.observe(response.headers) + return response.data.data @staticmethod async def _send_activity( @@ -400,20 +399,21 @@ async def _send_activity( room_id: str, working: bool, activity: AgentActivityDetail | None, - deadline: float, - ) -> ReportAgentChatActivityResponse: + timeout_seconds: int, + ) -> AsyncHttpResponse[ReportAgentChatActivityResponse]: kwargs: dict[str, Any] = {} if activity is not None: kwargs["activity"] = activity - remaining = deadline - asyncio.get_running_loop().time() - return await rest.agent_api_activity.report_agent_chat_activity( - chat_id=room_id, - working=working, - request_options={ - **NO_RETRY_REQUEST_OPTIONS, - "timeout_in_seconds": max(1, ceil(remaining)), - }, - **kwargs, + return ( + await rest.agent_api_activity.with_raw_response.report_agent_chat_activity( + chat_id=room_id, + working=working, + request_options={ + **NO_RETRY_REQUEST_OPTIONS, + "timeout_in_seconds": timeout_seconds, + }, + **kwargs, + ) ) async def get_next_message( diff --git a/src/band/runtime/sessionconfig.py b/src/band/runtime/sessionconfig.py index 9e21f6f58..05e5a98e6 100644 --- a/src/band/runtime/sessionconfig.py +++ b/src/band/runtime/sessionconfig.py @@ -48,7 +48,7 @@ class SessionConfig(BaseSettings): # Keep-alive cadence: how often working:true is refreshed while a cycle runs. # Must be < TTL/2 so a single missed/slow ping still lands inside the TTL. working_keep_alive_seconds: float = 3.0 - # Total deadline for discovery, POST and a compatibility fallback. + # Total deadline for one report's POST and its compatibility fallback. # Must be < cadence so a slow report cannot pile onto the next tick. working_request_timeout_seconds: int = 2 # Optional upper bound on how long we keep asserting working:true for one diff --git a/tests/platform/activity/conftest.py b/tests/platform/activity/conftest.py index 7734dfc78..10b7390a4 100644 --- a/tests/platform/activity/conftest.py +++ b/tests/platform/activity/conftest.py @@ -3,16 +3,16 @@ from collections.abc import AsyncIterator import pytest -from pytest_httpx import HTTPXMock +from band_rest import AgentActivityDetail import band.platform.message_lifecycle as lifecycle -from band.platform.activity import ActivitySupport +from band.platform.activity import API_VERSION_HEADER, ActivitySupport from band.platform.link import BandLink -from tests.platform.activity.kit import VERSION_URL, ActivityPeer +from tests.platform.activity.kit import DETAIL, SUPPORTED_VERSION, ActivityPeer from tests.runtime.helpers import rest_client_over -@pytest.fixture +@pytest.fixture(autouse=True) def support(monkeypatch: pytest.MonkeyPatch) -> ActivitySupport: support = ActivitySupport() monkeypatch.setattr(lifecycle, "activity_support", support) @@ -20,21 +20,32 @@ def support(monkeypatch: pytest.MonkeyPatch) -> ActivitySupport: @pytest.fixture -async def activity_peer( - httpx_mock: HTTPXMock, support: ActivitySupport -) -> ActivityPeer: - activity_peer = ActivityPeer() - httpx_mock.add_callback( - activity_peer.discover, url=VERSION_URL, is_optional=True, is_reusable=True - ) - return activity_peer +def activity_peer() -> ActivityPeer: + return ActivityPeer() + + +@pytest.fixture +def detail() -> AgentActivityDetail: + return AgentActivityDetail(**DETAIL) @pytest.fixture async def link(activity_peer: ActivityPeer) -> AsyncIterator[BandLink]: async with rest_client_over(activity_peer.respond) as rest: - link = BandLink( - agent_id="agent", api_key="test-key", rest_url="https://platform.test" - ) + link = BandLink(agent_id="agent", api_key="test-key") link.rest = rest yield link + + +@pytest.fixture +def second_link(link: BandLink) -> BandLink: + other = BandLink(agent_id="second-agent", api_key="test-key") + other.rest = link.rest + return other + + +@pytest.fixture +def learned(support: ActivitySupport) -> ActivitySupport: + """A process that already saw a supporting ``X-API-Version``.""" + support.observe({API_VERSION_HEADER: SUPPORTED_VERSION}) + return support diff --git a/tests/platform/activity/kit.py b/tests/platform/activity/kit.py index d282ae3a5..c48168ca7 100644 --- a/tests/platform/activity/kit.py +++ b/tests/platform/activity/kit.py @@ -7,38 +7,33 @@ import httpx -VERSION_URL = "https://platform.test/api/version" +from band.platform.activity import API_VERSION_HEADER, MINIMUM_ACTIVITY_VERSION + +SUPPORTED_VERSION = ".".join(map(str, MINIMUM_ACTIVITY_VERSION)) +DETAIL = {"kind": "tool_call", "name": "Bash"} class ActivityPeer: + """The activity endpoint, advertising ``version`` on every response.""" + def __init__(self) -> None: - self.version: object = {"agent_version": "1.25.0"} - self.version_status = 200 - self.version_content: bytes | None = None - self.version_requests: list[httpx.Request] = [] + self.version: str | None = SUPPORTED_VERSION self.reports: list[dict[str, Any]] = [] + self.requests: list[httpx.Request] = [] self.responses: deque[httpx.Response] = deque() - self.post_entered = asyncio.Event() - self.post_release = asyncio.Event() - self.hold_post_number: int | None = None - self.discovering = asyncio.Event() - self.discovery_release: asyncio.Event | None = None - - async def discover(self, request: httpx.Request) -> httpx.Response: - self.version_requests.append(request) - self.discovering.set() - if self.discovery_release is not None: - await self.discovery_release.wait() - if self.version_content is not None: - return httpx.Response(self.version_status, content=self.version_content) - return httpx.Response(self.version_status, json=self.version) + self.stall_post_number: int | None = None async def respond(self, request: httpx.Request) -> httpx.Response: body = json.loads(request.content) + self.requests.append(request) self.reports.append(body) - if len(self.reports) == self.hold_post_number: - self.post_entered.set() - await self.post_release.wait() - if self.responses: - return self.responses.popleft() - return httpx.Response(200, json={"data": {"working": body["working"]}}) + if len(self.reports) == self.stall_post_number: + await asyncio.Event().wait() + response = ( + self.responses.popleft() + if self.responses + else httpx.Response(200, json={"data": {"working": body["working"]}}) + ) + if self.version is not None: + response.headers[API_VERSION_HEADER] = self.version + return response diff --git a/tests/platform/activity/test_coordination.py b/tests/platform/activity/test_coordination.py deleted file mode 100644 index 060bfdd74..000000000 --- a/tests/platform/activity/test_coordination.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -import asyncio - -import pytest -from band_rest import AgentActivityDetail - -from band.platform.activity import ActivitySupport -from band.platform.link import BandLink -from tests.platform.activity.kit import VERSION_URL, ActivityPeer - - -class TestActivityCoordination: - async def test_concurrent_links_share_discovery_and_waiter_cancellation_is_independent( - self, link: BandLink, activity_peer: ActivityPeer - ) -> None: - activity_peer.discovery_release = asyncio.Event() - detail = AgentActivityDetail(kind="reasoning") - owner = asyncio.create_task( - link.report_activity("first", True, activity=detail) - ) - await activity_peer.discovering.wait() - other = BandLink( - agent_id="second", api_key="test", rest_url="https://platform.test" - ) - other.rest = link.rest - waiter = asyncio.create_task( - other.report_activity("second", True, activity=detail) - ) - await asyncio.sleep(0) - waiter.cancel() - with pytest.raises(asyncio.CancelledError): - await waiter - assert not owner.done() - activity_peer.discovery_release.set() - assert await owner - assert await other.report_activity("second", True, activity=detail) - assert len(activity_peer.version_requests) == 1 - assert ( - activity_peer.reports - == [{"working": True, "activity": {"kind": "reasoning"}}] * 2 - ) - - async def test_discovery_owner_cancellation_unblocks_future_callers_as_boolean( - self, link: BandLink, activity_peer: ActivityPeer - ) -> None: - activity_peer.discovery_release = asyncio.Event() - detail = AgentActivityDetail(kind="reasoning") - owner = asyncio.create_task( - link.report_activity("first", True, activity=detail) - ) - await activity_peer.discovering.wait() - owner.cancel() - with pytest.raises(asyncio.CancelledError): - await owner - assert await link.report_activity("next", True, activity=detail) - assert activity_peer.reports == [{"working": True}] - assert len(activity_peer.version_requests) == 1 - - def test_completed_negotiation_survives_later_event_loops( - self, support: ActivitySupport, activity_peer: ActivityPeer - ) -> None: - assert asyncio.run(support.allows(VERSION_URL.removesuffix("/api/version"), 1)) - assert asyncio.run(support.allows("https://unqueried.test", 1)) - assert len(activity_peer.version_requests) == 1 - - @pytest.mark.parametrize("failure", ["server", "malformed"]) - async def test_discovery_failure_is_cached_without_harming_boolean_reports( - self, link: BandLink, activity_peer: ActivityPeer, failure: str - ) -> None: - if failure == "server": - activity_peer.version_status = 500 - else: - activity_peer.version_content = b"not-json" - for room in ("first", "second"): - assert await link.report_activity( - room, True, activity=AgentActivityDetail(kind="memory") - ) - assert len(activity_peer.version_requests) == 1 - assert activity_peer.reports == [{"working": True}] * 2 diff --git a/tests/platform/activity/test_deadlines.py b/tests/platform/activity/test_deadlines.py index aec390434..330c5e25c 100644 --- a/tests/platform/activity/test_deadlines.py +++ b/tests/platform/activity/test_deadlines.py @@ -7,38 +7,26 @@ from band_rest import AgentActivityDetail from band.platform.link import BandLink -from tests.platform.activity.kit import ActivityPeer +from tests.platform.activity.kit import DETAIL, ActivityPeer @pytest.mark.looptime +@pytest.mark.usefixtures("learned") class TestActivityDeadline: - async def test_stalled_discovery_reserves_half_the_budget_for_boolean_post( - self, link: BandLink, activity_peer: ActivityPeer - ) -> None: - activity_peer.discovery_release = asyncio.Event() - started = asyncio.get_running_loop().time() - assert await link.report_activity( - "room", True, activity=AgentActivityDetail(kind="reasoning") - ) - elapsed = asyncio.get_running_loop().time() - started - assert elapsed <= 1.01 - assert activity_peer.reports == [{"working": True}] - assert len(activity_peer.version_requests) == 1 - async def test_retry_cannot_extend_total_deadline_and_downgrade_survives_timeout( - self, link: BandLink, activity_peer: ActivityPeer + self, link: BandLink, activity_peer: ActivityPeer, detail: AgentActivityDetail ) -> None: activity_peer.responses.append(httpx.Response(422)) - activity_peer.hold_post_number = 2 - detail = AgentActivityDetail(kind="tool_call", name="Bash") + activity_peer.stall_post_number = 2 started = asyncio.get_running_loop().time() - assert not await link.report_activity("room", True, activity=detail) - elapsed = asyncio.get_running_loop().time() - started - assert elapsed <= 2.01 + assert not await link.report_activity( + "room", True, activity=detail, timeout_seconds=2 + ) + assert asyncio.get_running_loop().time() - started <= 2.01 assert activity_peer.reports == [ - {"working": True, "activity": {"kind": "tool_call", "name": "Bash"}}, + {"working": True, "activity": DETAIL}, {"working": True}, ] - activity_peer.hold_post_number = None + activity_peer.stall_post_number = None assert await link.report_activity("next", True, activity=detail) assert activity_peer.reports[-1] == {"working": True} diff --git a/tests/platform/activity/test_fallback.py b/tests/platform/activity/test_fallback.py index c88eac256..648121f56 100644 --- a/tests/platform/activity/test_fallback.py +++ b/tests/platform/activity/test_fallback.py @@ -6,49 +6,69 @@ import pytest from band_rest import AgentActivityDetail +from band.platform.activity import ActivitySupport from band.platform.link import BandLink -from tests.platform.activity.kit import ActivityPeer +from tests.platform.activity.kit import DETAIL, ActivityPeer +@pytest.mark.usefixtures("learned") class TestActivityFallback: @pytest.mark.parametrize( "body", [b'{"errors":{"activity":["unknown"]}}', b"not json", b""] ) async def test_422_retries_without_detail_and_latches_across_links( - self, link: BandLink, activity_peer: ActivityPeer, body: bytes + self, + link: BandLink, + second_link: BandLink, + activity_peer: ActivityPeer, + detail: AgentActivityDetail, + body: bytes, ) -> None: activity_peer.responses.append(httpx.Response(422, content=body)) - detail = AgentActivityDetail(kind="tool_call", name="Read") assert await link.report_activity("first", True, activity=detail) - other = BandLink( - agent_id="other-agent", api_key="test-key", rest_url="https://other.test" - ) - other.rest = link.rest - assert await other.report_activity("second", True, activity=detail) + assert await second_link.report_activity("second", True, activity=detail) assert activity_peer.reports == [ - {"working": True, "activity": {"kind": "tool_call", "name": "Read"}}, + {"working": True, "activity": DETAIL}, {"working": True}, {"working": True}, ] - assert len(activity_peer.version_requests) == 1 + + @pytest.mark.parametrize( + ("working", "activity"), + [(True, None), (False, AgentActivityDetail(**DETAIL))], + ids=["no-detail", "idle"], + ) + async def test_422_without_detail_neither_retries_nor_latches( + self, + link: BandLink, + activity_peer: ActivityPeer, + learned: ActivitySupport, + detail: AgentActivityDetail, + working: bool, + activity: AgentActivityDetail | None, + ) -> None: + activity_peer.responses.append(httpx.Response(422)) + assert not await link.report_activity("room", working, activity=activity) + assert len(activity_peer.reports) == 1 + assert learned.supported @pytest.mark.parametrize("status", [400, 403, 500]) async def test_other_errors_do_not_retry_or_disable( - self, link: BandLink, activity_peer: ActivityPeer, status: int + self, + link: BandLink, + activity_peer: ActivityPeer, + detail: AgentActivityDetail, + status: int, ) -> None: activity_peer.responses.append( httpx.Response(status, json={"error": "failure"}) ) - detail = AgentActivityDetail(kind="reasoning") assert not await link.report_activity("room", True, activity=detail) assert await link.report_activity("room", True, activity=detail) - assert ( - activity_peer.reports - == [{"working": True, "activity": {"kind": "reasoning"}}] * 2 - ) + assert activity_peer.reports == [{"working": True, "activity": DETAIL}] * 2 async def test_fallback_echoed_false_keeps_stop_notification( - self, link: BandLink, activity_peer: ActivityPeer + self, link: BandLink, activity_peer: ActivityPeer, detail: AgentActivityDetail ) -> None: activity_peer.responses.extend( [ @@ -58,10 +78,7 @@ async def test_fallback_echoed_false_keeps_stop_notification( ) stopped = Mock() assert await link.report_activity( - "room", - True, - activity=AgentActivityDetail(kind="reasoning"), - on_stopped=stopped, + "room", True, activity=detail, on_stopped=stopped ) stopped.assert_called_once_with() assert activity_peer.reports[-1] == {"working": True} diff --git a/tests/platform/activity/test_reporting.py b/tests/platform/activity/test_reporting.py new file mode 100644 index 000000000..bfc8bb51f --- /dev/null +++ b/tests/platform/activity/test_reporting.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import logging + +import httpx +import pytest + +from band.platform.link import BandLink +from tests.platform.activity.kit import ActivityPeer + + +class TestActivityReporting: + @pytest.mark.parametrize("working", [True, False]) + async def test_reports_the_boolean_state_to_the_room( + self, link: BandLink, activity_peer: ActivityPeer, working: bool + ) -> None: + assert await link.report_activity("room-1", working) + assert activity_peer.reports == [{"working": working}] + assert activity_peer.requests[-1].url.path.endswith("/chats/room-1/activity") + + async def test_post_is_bounded_and_never_retried( + self, link: BandLink, activity_peer: ActivityPeer + ) -> None: + activity_peer.responses.append(httpx.Response(500)) + assert not await link.report_activity("room-1", True, timeout_seconds=2) + [request] = activity_peer.requests + assert request.extensions["timeout"]["read"] == 2 + + async def test_repeated_failures_warn_once_then_recover( + self, + link: BandLink, + activity_peer: ActivityPeer, + caplog: pytest.LogCaptureFixture, + ) -> None: + activity_peer.responses.extend([httpx.Response(500), httpx.Response(500)]) + with caplog.at_level(logging.INFO, logger="band.platform.message_lifecycle"): + assert not await link.report_activity("room-1", True) + assert not await link.report_activity("room-1", True) + assert await link.report_activity("room-1", True) + assert [record.levelno for record in caplog.records] == [ + logging.WARNING, + logging.INFO, + ] + assert "recovered" in caplog.records[-1].getMessage() diff --git a/tests/platform/activity/test_versions.py b/tests/platform/activity/test_versions.py index 1eb74f30e..c8bcf135f 100644 --- a/tests/platform/activity/test_versions.py +++ b/tests/platform/activity/test_versions.py @@ -1,62 +1,90 @@ from __future__ import annotations +import logging + import pytest from band_rest import AgentActivityDetail +from band.config.settings import DEFAULT_REST_URL from band.platform.link import BandLink -from tests.platform.activity.kit import ActivityPeer +from tests.platform.activity.kit import DETAIL, SUPPORTED_VERSION, ActivityPeer class TestActivityVersion: @pytest.mark.parametrize( ("version", "supported"), [ - ({"agent_version": "1.24.99"}, False), - ({"agent_version": "1.25.0"}, True), - ({"agent_version": "1.26.10"}, True), - ({"agent_version": "2.0.0"}, True), - ({"platform_version": "999.0.0"}, False), - ({"agent_version": "1.25.0-beta"}, False), - ({"agent_version": "broken"}, False), - ({"agent_version": "1.25"}, False), - ({"agent_version": None}, False), - ([], False), + ("1.24.99", False), + (SUPPORTED_VERSION, True), + ("1.26.10", True), + ("2.0.0", True), + ("1.25.0-beta", False), + ("broken", False), + ("1.25", False), + ("2.0", False), + (None, False), ], ) - async def test_agent_version_controls_real_request_detail( + async def test_advertised_version_controls_later_detail( self, link: BandLink, activity_peer: ActivityPeer, - version: object, + detail: AgentActivityDetail, + version: str | None, supported: bool, ) -> None: activity_peer.version = version - detail = AgentActivityDetail(kind="tool_call", name="Bash") assert await link.report_activity("room", True, activity=detail) - assert await link.report_activity("other", True, activity=detail) - assert len(activity_peer.version_requests) == 1 - expected = {"working": True} - if supported: - expected["activity"] = {"kind": "tool_call", "name": "Bash"} - assert activity_peer.reports == [expected, expected] - assert "authorization" not in activity_peer.version_requests[0].headers + assert await link.report_activity("room", True, activity=detail) + later = ( + {"working": True, "activity": DETAIL} if supported else {"working": True} + ) + assert activity_peer.reports == [{"working": True}, later] + + async def test_default_saas_url_learns_detail_from_activity_responses( + self, link: BandLink, activity_peer: ActivityPeer, detail: AgentActivityDetail + ) -> None: + assert link.rest_url == DEFAULT_REST_URL + assert await link.report_activity("room", True, activity=detail) + assert await link.report_activity("room", True, activity=detail) + assert activity_peer.reports[-1] == {"working": True, "activity": DETAIL} + assert all( + request.url.path.endswith("/activity") for request in activity_peer.requests + ) + + async def test_learned_support_is_shared_across_links( + self, + link: BandLink, + second_link: BandLink, + activity_peer: ActivityPeer, + detail: AgentActivityDetail, + ) -> None: + assert await link.report_activity("first", True) + assert await second_link.report_activity("second", True, activity=detail) + assert activity_peer.reports[-1] == {"working": True, "activity": DETAIL} @pytest.mark.parametrize("kind", ["reasoning", "memory", "permission"]) async def test_non_tool_detail_omits_name_and_false_omits_detail( self, link: BandLink, activity_peer: ActivityPeer, kind: str ) -> None: detail = AgentActivityDetail(kind=kind) + assert await link.report_activity("room", True) assert await link.report_activity("room", True, activity=detail) assert await link.report_activity("room", False, activity=detail) - assert activity_peer.reports == [ + assert activity_peer.reports[1:] == [ {"working": True, "activity": {"kind": kind}}, {"working": False}, ] - async def test_boolean_callers_do_not_discover( - self, link: BandLink, activity_peer: ActivityPeer + async def test_unadvertised_version_is_logged_for_diagnosis( + self, + link: BandLink, + activity_peer: ActivityPeer, + caplog: pytest.LogCaptureFixture, ) -> None: - assert await link.report_activity("room", True) - assert await link.report_activity("room", False) - assert not activity_peer.version_requests - assert activity_peer.reports == [{"working": True}, {"working": False}] + activity_peer.version = "1.24.0" + with caplog.at_level(logging.DEBUG, logger="band.platform.activity"): + assert await link.report_activity("room", True) + [record] = caplog.records + assert record.levelno == logging.DEBUG + assert "'1.24.0'" in record.getMessage() diff --git a/tests/platform/test_link.py b/tests/platform/test_link.py index d6401a71d..8a17eeb79 100644 --- a/tests/platform/test_link.py +++ b/tests/platform/test_link.py @@ -3,16 +3,9 @@ from __future__ import annotations import asyncio -import logging from unittest.mock import ANY, AsyncMock, MagicMock, create_autospec, patch import pytest -from band_rest import ( - AsyncRestClient, - NotFoundError, - UnauthorizedError, - UnprocessableEntityError, -) from band_rest.core.api_error import ApiError from band_rest.types.chat_message_metadata import ChatMessageMetadata from band_sdk_core import AgentTopicStatus, DeadReason, SessionState, chat_room_topic @@ -1346,135 +1339,3 @@ async def test_returns_empty_list_on_failure(self): messages = await link.get_stale_processing_messages("room-1") assert messages == [] - - -class TestReportActivity: - """Tests for BandLink.report_activity (boolean working-state reporting).""" - - @pytest.mark.asyncio - async def test_reports_working_true(self): - link = BandLink(agent_id="agent-123", api_key="test-key") - link.rest = MagicMock() - link.rest.agent_api_activity.report_agent_chat_activity = AsyncMock() - - result = await link.report_activity("room-1", True) - - assert result is True - call = link.rest.agent_api_activity.report_agent_chat_activity - call.assert_awaited_once() - assert call.call_args.kwargs["chat_id"] == "room-1" - assert call.call_args.kwargs["working"] is True - - @pytest.mark.asyncio - async def test_passes_per_post_timeout_and_no_retries(self): - link = BandLink(agent_id="agent-123", api_key="test-key") - link.rest = MagicMock() - link.rest.agent_api_activity.report_agent_chat_activity = AsyncMock() - - await link.report_activity("room-1", True, timeout_seconds=2) - - opts = link.rest.agent_api_activity.report_agent_chat_activity.call_args.kwargs[ - "request_options" - ] - assert opts["timeout_in_seconds"] == 2 - assert opts["max_retries"] == 0 - - @pytest.mark.asyncio - async def test_reports_working_false(self): - link = BandLink(agent_id="agent-123", api_key="test-key") - link.rest = MagicMock() - link.rest.agent_api_activity.report_agent_chat_activity = AsyncMock() - - result = await link.report_activity("room-1", False) - - assert result is True - call = link.rest.agent_api_activity.report_agent_chat_activity - assert call.call_args.kwargs["working"] is False - - @pytest.mark.asyncio - async def test_returns_false_on_not_found(self): - - link = BandLink(agent_id="agent-123", api_key="test-key") - link.rest = MagicMock() - link.rest.agent_api_activity.report_agent_chat_activity = AsyncMock( - side_effect=NotFoundError(headers={}, body="no active execution") - ) - - result = await link.report_activity("room-1", True) - - assert result is False - - @pytest.mark.asyncio - async def test_returns_false_on_unauthorized(self): - - link = BandLink(agent_id="agent-123", api_key="test-key") - link.rest = MagicMock() - link.rest.agent_api_activity.report_agent_chat_activity = AsyncMock( - side_effect=UnauthorizedError(headers={}, body="bad key") - ) - - result = await link.report_activity("room-1", True) - - assert result is False - - @pytest.mark.asyncio - async def test_returns_false_on_unprocessable_entity(self): - - link = BandLink(agent_id="agent-123", api_key="test-key") - link.rest = MagicMock() - link.rest.agent_api_activity.report_agent_chat_activity = AsyncMock( - side_effect=UnprocessableEntityError(headers={}, body="bad uuid") - ) - - result = await link.report_activity("room-1", True) - - assert result is False - - @pytest.mark.asyncio - async def test_returns_false_on_network_error(self): - link = BandLink(agent_id="agent-123", api_key="test-key") - link.rest = MagicMock() - link.rest.agent_api_activity.report_agent_chat_activity = AsyncMock( - side_effect=Exception("network down") - ) - - result = await link.report_activity("room-1", True) - - assert result is False - - def test_real_client_exposes_activity_method(self): - """Guard: the real REST client must actually expose the activity method. - - The AsyncMock-based tests above auto-fabricate attributes, so they would - stay green even if `agent_api_activity.report_agent_chat_activity` - disappeared or was renamed in a band_rest bump. This test pins the - real wire contract: instantiate the real client and assert the method - exists and is callable. - """ - - client = AsyncRestClient(api_key="test-key", base_url="https://test.com") - method = getattr(client.agent_api_activity, "report_agent_chat_activity", None) - assert callable(method) - - @pytest.mark.asyncio - async def test_repeated_failures_warn_once_then_recover(self, caplog): - - link = BandLink(agent_id="agent-123", api_key="test-key") - link.rest = MagicMock() - link.rest.agent_api_activity.report_agent_chat_activity = AsyncMock( - side_effect=Exception("down") - ) - - with caplog.at_level(logging.WARNING, logger="band.platform.message_lifecycle"): - assert await link.report_activity("room-1", True) is False - assert await link.report_activity("room-1", True) is False - - warnings = [r for r in caplog.records if r.levelno == logging.WARNING] - assert len(warnings) == 1 # debounced: only the first failure warns - - # Recovery logs once at INFO and re-arms the warning. - link.rest.agent_api_activity.report_agent_chat_activity = AsyncMock() - with caplog.at_level(logging.INFO, logger="band.platform.message_lifecycle"): - caplog.clear() - assert await link.report_activity("room-1", True) is True - assert any("recovered" in r.message.lower() for r in caplog.records) diff --git a/tests/runtime/activityownership/conftest.py b/tests/runtime/activityownership/conftest.py index 7fa9fec6e..4b2e227bd 100644 --- a/tests/runtime/activityownership/conftest.py +++ b/tests/runtime/activityownership/conftest.py @@ -4,10 +4,7 @@ from unittest.mock import AsyncMock import pytest -from pytest_httpx import HTTPXMock -import band.platform.message_lifecycle as lifecycle -from band.platform.activity import ActivitySupport from band.platform.event import PlatformEvent from band.platform.link import BandLink from band.runtime.execution import ExecutionContext @@ -22,16 +19,8 @@ @pytest.fixture -async def activity_platform( - monkeypatch: pytest.MonkeyPatch, httpx_mock: HTTPXMock -) -> AsyncIterator[ActivityPlatform]: - monkeypatch.setattr(lifecycle, "activity_support", ActivitySupport()) - httpx_mock.add_response( - url="https://app.band.ai/api/version", - json={"agent_version": "1.25.0"}, - is_optional=True, - ) - yield ActivityPlatform() +def activity_platform() -> ActivityPlatform: + return ActivityPlatform() @pytest.fixture diff --git a/tests/runtime/conftest.py b/tests/runtime/conftest.py index 0a26403e8..4b723a5f0 100644 --- a/tests/runtime/conftest.py +++ b/tests/runtime/conftest.py @@ -34,9 +34,10 @@ import band.platform.message_lifecycle as lifecycle from band.core.types import PlatformConnection, PlatformMessage -from band.platform.activity import ActivitySupport +from band.platform.activity import API_VERSION_HEADER, ActivitySupport from band.runtime.presence import RoomPresence from tests.conftest import make_participant_mock +from tests.platform.activity.kit import SUPPORTED_VERSION from tests.runtime.helpers import AGENT_ID @@ -265,11 +266,7 @@ def link(mock_rest_client: MagicMock) -> MagicMock: @pytest.fixture(autouse=True) def runtime_activity_support(monkeypatch: pytest.MonkeyPatch) -> None: - """Keep deployment discovery independent of runtime scheduling regressions.""" + """Start every runtime test on a platform that already advertised detail.""" support = ActivitySupport() - - async def discover(rest_url: str, budget: float) -> bool: - return True - - monkeypatch.setattr(support, "_discover", discover) + support.observe({API_VERSION_HEADER: SUPPORTED_VERSION}) monkeypatch.setattr(lifecycle, "activity_support", support) From eac1fddc031bb1a144203e01cabc774a7369ca14 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 20 Oct 2026 17:25:56 +0300 Subject: [PATCH 12/33] fix: Forward activity detail through the baseline ControlLink Every working report now passes activity=; the baseline ControlLink override lacked the keyword, so its TypeError was swallowed and STOP-echo smokes never saw working:true. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb --- src/band/runtime/execution.py | 2 +- tests/e2e/baseline/toolkit/control.py | 9 ++++++++- tests/platform/activity/test_control_link.py | 21 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 tests/platform/activity/test_control_link.py diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index 193b171b2..e8297ed52 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -1892,7 +1892,7 @@ async def _report_working_state(self, working: bool) -> bool: return await self.link.report_activity( self.room_id, working, - **({"activity": detail} if detail is not None else {}), + activity=detail, timeout_seconds=self.config.working_request_timeout_seconds, on_stopped=partial( self._observe_activity_stop, diff --git a/tests/e2e/baseline/toolkit/control.py b/tests/e2e/baseline/toolkit/control.py index 70e5c1764..2b5087d92 100644 --- a/tests/e2e/baseline/toolkit/control.py +++ b/tests/e2e/baseline/toolkit/control.py @@ -7,6 +7,8 @@ from collections.abc import AsyncGenerator, Callable from contextlib import asynccontextmanager +from band_rest.types.agent_activity_detail import AgentActivityDetail + import band.runtime from band.client.streaming import ( AgentControlPayload, @@ -195,6 +197,7 @@ async def report_activity( room_id: str, working: bool, *, + activity: AgentActivityDetail | None = None, timeout_seconds: int = 2, on_stopped: Callable[[], None] | None = None, ) -> bool: @@ -205,7 +208,11 @@ def observe_stop() -> None: on_stopped() accepted = await super().report_activity( - room_id, working, timeout_seconds=timeout_seconds, on_stopped=observe_stop + room_id, + working, + activity=activity, + timeout_seconds=timeout_seconds, + on_stopped=observe_stop, ) if accepted and not working and room_id == self.control_room_id: self.control.activity_idle.set() diff --git a/tests/platform/activity/test_control_link.py b/tests/platform/activity/test_control_link.py new file mode 100644 index 000000000..008781d86 --- /dev/null +++ b/tests/platform/activity/test_control_link.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import pytest +from band_rest import AgentActivityDetail + +from band.platform.link import BandLink +from tests.e2e.baseline.toolkit.control import ControlLink, ControlRuntime +from tests.platform.activity.kit import DETAIL, ActivityPeer + + +@pytest.mark.usefixtures("learned") +class TestControlLinkActivity: + async def test_baseline_control_link_forwards_activity_detail( + self, link: BandLink, activity_peer: ActivityPeer, detail: AgentActivityDetail + ) -> None: + control_link = ControlLink(agent_id="agent", api_key="test-key") + control_link.rest = link.rest + control_link.control = ControlRuntime() + control_link.control_room_id = "room" + assert await control_link.report_activity("room", True, activity=detail) + assert activity_peer.reports == [{"working": True, "activity": DETAIL}] From 9a1430b685456d02d36edcd0e21a1a257af86439 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 20 Oct 2026 17:25:56 +0300 Subject: [PATCH 13/33] refactor: Keep one detail reset and one ownership recheck in the reporter The activity-clear seam only ran for already-aborted scopes, which never own activity, so it never cleared anything; aborts reset detail through the false report. Detail resets share one helper and constant, tool_call naming has one rule, and observations are checked for ownership only under the report lock, so a queued stale observation is provably dropped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb --- src/band/runtime/execution.py | 23 -------- src/band/runtime/working_state.py | 41 +++++++------- .../runtime/activityownership/test_detail.py | 53 ++++++++++++++++--- tests/runtime/workingstate/test_detail.py | 36 +++++++++++++ 4 files changed, 101 insertions(+), 52 deletions(-) diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index e8297ed52..b91312fc1 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -446,10 +446,6 @@ def __init__( # The first stop's graceful result; a retry only settles retained work. self._stop_graceful = True - self._on_activity_clear: Callable[[], Awaitable[None]] | None = ( - self._clear_scope_activity - ) - @property def thread_id(self) -> str: """LangGraph thread_id = room_id.""" @@ -884,17 +880,6 @@ async def _stop_retained_tasks(self, deadline: float) -> None: "task(s) still running" ) - async def _clear_activity(self) -> None: - """Invoke the optional activity-clear seam after an aborted cycle.""" - if self._on_activity_clear is None: - return - try: - await self._on_activity_clear() - except Exception: - logger.exception( - "ExecutionContext %s: activity-clear hook failed", self.room_id - ) - # --- Participant management --- def add_participant(self, participant: dict[str, Any]) -> bool: @@ -1983,13 +1968,6 @@ async def observe_activity( waiting=waiting, ) - async def _clear_scope_activity(self) -> None: - scope = self.current_scope - if scope is not None: - await self._working_reporter.clear_detail( - current=partial(self.owns_activity, scope) - ) - def register_turn_task(self, scope: TurnScope, owner: TurnTask) -> None: """Bind completion to the same owner that delivers worker cancellation.""" scope.bind(owner) @@ -2200,7 +2178,6 @@ async def _abort_cycle(self, kind: ControlMode, msg_id: str | None) -> bool: # poisoned into permanently_failed before it's ever actually attempted. if msg_id: self._retry_tracker.discard_attempt(msg_id) - await self._clear_activity() if kind is ControlMode.INTERRUPT and msg_id: # Consume the message so the idle /next resync does not re-return it # (excludes-only-processed) and re-fire the cycle the user just diff --git a/src/band/runtime/working_state.py b/src/band/runtime/working_state.py index 318a9aac6..c60fb4403 100644 --- a/src/band/runtime/working_state.py +++ b/src/band/runtime/working_state.py @@ -20,6 +20,7 @@ UNKNOWN_ACTIVITY_TOOL_NAME = "unknown" +REASONING = AgentActivityDetail(kind="reasoning") class ActivityObserver(Protocol): @@ -28,22 +29,25 @@ async def update(self, activity: AgentActivityDetail) -> None: ... async def set_manual_wait(self, waiting: bool) -> None: ... +def tool_call_activity(name: str | None) -> AgentActivityDetail: + """The platform requires a name on every ``tool_call`` detail.""" + return AgentActivityDetail( + kind="tool_call", name=name or UNKNOWN_ACTIVITY_TOOL_NAME + ) + + def _observer(tools: object) -> ActivityObserver | None: return getattr(tools, "_activity_observer", None) async def report_tool_activity(tools: object, name: str) -> None: if observer := _observer(tools): - await observer.update( - AgentActivityDetail( - kind="tool_call", name=name or UNKNOWN_ACTIVITY_TOOL_NAME - ) - ) + await observer.update(tool_call_activity(name)) async def end_tool_activity(tools: object) -> None: if observer := _observer(tools): - await observer.update(AgentActivityDetail(kind="reasoning")) + await observer.update(REASONING) async def report_memory_activity(tools: object) -> None: @@ -83,7 +87,7 @@ def __init__( self._active = False self._cycle: object | None = None - self._ordinary = AgentActivityDetail(kind="reasoning") + self._ordinary = REASONING self._manual_wait = False self._started_at: float | None = None self._task: asyncio.Task[None] | None = None @@ -112,8 +116,7 @@ async def start(self, *, owner: object | None = None) -> None: self._active = True cycle = owner if owner is not None else object() self._cycle = cycle - self._ordinary = AgentActivityDetail(kind="reasoning") - self._manual_wait = False + self._reset_detail() self._started_at = asyncio.get_running_loop().time() await self._report_locked(True) if self._cycle is cycle and self._active: @@ -128,7 +131,7 @@ async def update( activity: AgentActivityDetail | None = None, waiting: bool | None = None, ) -> None: - if not current() or not self._enabled: + if not self._enabled: return async with self._lock: if not current() or not self._active or self._cap_reached(): @@ -136,10 +139,7 @@ async def update( previous = self.activity if activity is not None: self._ordinary = ( - AgentActivityDetail( - kind="tool_call", - name=activity.name or UNKNOWN_ACTIVITY_TOOL_NAME, - ) + tool_call_activity(activity.name) if activity.kind == "tool_call" else AgentActivityDetail(kind=activity.kind) ) @@ -148,12 +148,6 @@ async def update( if self.activity != previous: await self._report_locked(True) - async def clear_detail(self, *, current: Callable[[], bool]) -> None: - async with self._lock: - if current(): - self._ordinary = AgentActivityDetail(kind="reasoning") - self._manual_wait = False - def defer_false_report(self) -> None: """Start ``working: false`` and return without waiting for it. @@ -198,14 +192,17 @@ async def stop(self) -> None: def _arm_false_report(self) -> asyncio.Task[None] | None: self._active = False self._cycle = None - self._ordinary = AgentActivityDetail(kind="reasoning") - self._manual_wait = False + self._reset_detail() task, self._task = self._task, None if task is not None: task.cancel() self._false_send = asyncio.ensure_future(self._safe_report(False)) return task + def _reset_detail(self) -> None: + self._ordinary = REASONING + self._manual_wait = False + async def _finish_false_send(self) -> None: send = self._false_send if send is None: diff --git a/tests/runtime/activityownership/test_detail.py b/tests/runtime/activityownership/test_detail.py index 83e7bdf77..11fa346c6 100644 --- a/tests/runtime/activityownership/test_detail.py +++ b/tests/runtime/activityownership/test_detail.py @@ -4,7 +4,6 @@ from collections.abc import Callable import pytest -from pytest_httpx import HTTPXMock from band.core.turn_lifecycle import TurnTask, bind_turn_task from band.platform.event import PlatformEvent @@ -95,23 +94,24 @@ async def test_queued_observation_is_rechecked_after_stop_and_replacement( activity_platform: ActivityPlatform, ) -> None: captured: list[AgentTools] = [] + handler_started = asyncio.Event() release = asyncio.Event() async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: captured.append(AgentTools.from_context(ctx)) + handler_started.set() await release.wait() ctx = execution(handler) turn = asyncio.create_task(ctx._process_event(make_message_event(msg_id="old"))) - while not captured: - await asyncio.sleep(0) + await handler_started.wait() activity_platform.hold_next_activity = True change = asyncio.create_task(report_tool_activity(captured[0], "Bash")) await activity_platform.activity_entered.wait() stale = asyncio.create_task(report_memory_activity(captured[0])) ctx.interrupt() activity_platform.activity_release.set() - await asyncio.gather(turn, change, stale, return_exceptions=True) + await asyncio.gather(turn, change, stale) release.set() await ctx._process_event(make_message_event(msg_id="new")) assert activity_platform.activity_bodies[-3:] == [ @@ -124,12 +124,52 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: for body in activity_platform.activity_bodies ) + @pytest.mark.looptime + async def test_waiting_delivery_takes_over_reporting_once_the_owner_finishes( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + worker_release = asyncio.Event() + waiting_tools: list[AgentTools] = [] + waiting_started = asyncio.Event() + waiting_release = asyncio.Event() + workers: list[TurnTask] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + if workers: + waiting_tools.append(tools) + waiting_started.set() + await waiting_release.wait() + return + workers.append( + bind_turn_task(tools, asyncio.create_task(worker_release.wait())) + ) + + ctx = execution(handler) + await ctx._process_event(make_message_event(msg_id="owner")) + waiting = asyncio.create_task( + ctx._process_event(make_message_event(msg_id="waiting")) + ) + await waiting_started.wait() + worker_release.set() + await workers[0].task + await ctx._join_activity_completion() + await report_tool_activity(waiting_tools[0], "Bash") + assert activity_platform.activity_bodies[-3:] == [ + {"working": False}, + {"working": True, "activity": {"kind": "reasoning"}}, + {"working": True, "activity": {"kind": "tool_call", "name": "Bash"}}, + ] + waiting_release.set() + await waiting + @pytest.mark.parametrize("excluded", ["disabled", "hub"]) - async def test_excluded_reporting_ignores_observations_without_discovery( + async def test_excluded_reporting_ignores_observations( self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform, - httpx_mock: HTTPXMock, excluded: str, ) -> None: async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: @@ -147,4 +187,3 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: ctx = execution(handler, **settings) await ctx._process_event(make_message_event()) assert not activity_platform.activity_bodies - assert not httpx_mock.get_requests() diff --git a/tests/runtime/workingstate/test_detail.py b/tests/runtime/workingstate/test_detail.py index 4d776d1fb..c4dd08167 100644 --- a/tests/runtime/workingstate/test_detail.py +++ b/tests/runtime/workingstate/test_detail.py @@ -104,3 +104,39 @@ async def report(working: bool) -> None: finally: await reporter.stop() assert reports == [True, False] + + async def test_observation_queued_behind_a_report_is_rechecked_under_the_lock( + self, + ) -> None: + entered, release = asyncio.Event(), asyncio.Event() + reports: list[str] = [] + + async def report(working: bool) -> None: + if working: + reports.append(reporter.activity.kind) + if reporter.activity.kind == "tool_call": + entered.set() + await release.wait() + + reporter = WorkingStateReporter(report) + owned = True + try: + await reporter.start() + change = asyncio.create_task( + reporter.update( + current=lambda: owned, + activity=AgentActivityDetail(kind="tool_call", name="Bash"), + ) + ) + await entered.wait() + stale = asyncio.create_task( + reporter.update( + current=lambda: owned, activity=AgentActivityDetail(kind="memory") + ) + ) + owned = False + release.set() + await asyncio.gather(change, stale) + finally: + await reporter.stop() + assert reports == ["reasoning", "tool_call"] From 435ae73b98878878e15f046f799a0efef947d6bc Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 20 Oct 2026 17:25:56 +0300 Subject: [PATCH 14/33] test: Scope the recovery-jitter patch to resync's random reference Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb --- tests/runtime/execution/test_crashrecoverysync.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/runtime/execution/test_crashrecoverysync.py b/tests/runtime/execution/test_crashrecoverysync.py index 22e71bcd9..b779bf812 100644 --- a/tests/runtime/execution/test_crashrecoverysync.py +++ b/tests/runtime/execution/test_crashrecoverysync.py @@ -2,12 +2,14 @@ import asyncio from datetime import UTC, datetime +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, call import pytest from band_sdk_core import ClaimRegistry from band.client.streaming import MessageMetadata +from band.runtime import resync from band.runtime.execution import ( ExecutionContext, ResyncOutcome, @@ -1069,8 +1071,9 @@ async def test_startup_backlog_claim_failure_does_not_process_newer_ws_event( ) monkeypatch.setattr( - "band.runtime.resync.random.uniform", - lambda lower, upper: initial_recovery_delay, + resync, + "random", + SimpleNamespace(uniform=lambda lower, upper: initial_recovery_delay), ) await ctx.on_event( From 90bc52af84cfe5a2a10d46a36dd6951f744da7ce Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 20 Oct 2026 18:08:55 +0300 Subject: [PATCH 15/33] fix: Log a content-free cause when activity detail is downgraded The downgrade logged the whole 422 body at INFO and called it a rejection before the boolean resend ran. It now logs the status and the first rejected JSON pointer, with the body at DEBUG. The activity POST helpers become module functions instead of class-qualified staticmethods. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb --- src/band/platform/activity.py | 6 +- src/band/platform/message_lifecycle.py | 99 +++++++++++++----------- tests/platform/activity/test_fallback.py | 38 ++++++++- 3 files changed, 90 insertions(+), 53 deletions(-) diff --git a/src/band/platform/activity.py b/src/band/platform/activity.py index aeff03672..b9c9b6e3f 100644 --- a/src/band/platform/activity.py +++ b/src/band/platform/activity.py @@ -50,16 +50,16 @@ def observe(self, headers: Mapping[str, str]) -> None: with self._lock: self._learned = True - def disable(self, *, room_id: str, reason: object) -> None: + def disable(self, *, room_id: str, field: str) -> None: with self._lock: already_disabled = self._disabled self._disabled = True if not already_disabled: logger.info( - "Activity detail rejected in room %s (%s); " + "Activity report with detail returned 422 in room %s (field %s); " "using boolean reporting for this process", room_id, - reason, + field, ) diff --git a/src/band/platform/message_lifecycle.py b/src/band/platform/message_lifecycle.py index 16b990ca2..cb17ad193 100644 --- a/src/band/platform/message_lifecycle.py +++ b/src/band/platform/message_lifecycle.py @@ -116,6 +116,57 @@ def _failure_result(error: Exception) -> SettlementResult: return SettlementResult.UNCONFIRMED +def _first_rejected_field(body: object) -> str: + """A content-free cause: the first JSON pointer a validation error names.""" + envelope = _validated(ErrorEnvelope, body) + if envelope is None: + return "unparsed" + return next(iter(envelope.error.details), "unnamed") + + +async def _post_activity( + rest: AsyncRestClient, + room_id: str, + working: bool, + activity: AgentActivityDetail | None, + timeout_seconds: int, +) -> AgentActivityResponse: + detail = activity if activity_support.supported else None + try: + response = await _send_activity(rest, room_id, working, detail, timeout_seconds) + except ApiError as error: + if detail is None or error.status_code != HTTPStatus.UNPROCESSABLE_ENTITY: + raise + activity_support.disable( + room_id=room_id, field=_first_rejected_field(error.body) + ) + logger.debug("Activity 422 body for room %s: %r", room_id, error.body) + response = await _send_activity(rest, room_id, working, None, timeout_seconds) + activity_support.observe(response.headers) + return response.data.data + + +async def _send_activity( + rest: AsyncRestClient, + room_id: str, + working: bool, + activity: AgentActivityDetail | None, + timeout_seconds: int, +) -> AsyncHttpResponse[ReportAgentChatActivityResponse]: + kwargs: dict[str, Any] = {} + if activity is not None: + kwargs["activity"] = activity + return await rest.agent_api_activity.with_raw_response.report_agent_chat_activity( + chat_id=room_id, + working=working, + request_options={ + **NO_RETRY_REQUEST_OPTIONS, + "timeout_in_seconds": timeout_seconds, + }, + **kwargs, + ) + + def _platform_message(item: ChatMessage, room_id: str) -> PlatformMessage: return PlatformMessage( id=item.id, @@ -337,7 +388,7 @@ async def report_activity( """ try: async with asyncio.timeout(timeout_seconds): - response = await self._post_activity( + response = await _post_activity( rest, room_id, working, @@ -370,52 +421,6 @@ async def report_activity( on_stopped() return True - @staticmethod - async def _post_activity( - rest: AsyncRestClient, - room_id: str, - working: bool, - activity: AgentActivityDetail | None, - timeout_seconds: int, - ) -> AgentActivityResponse: - detail = activity if activity_support.supported else None - try: - response = await MessageLifecycle._send_activity( - rest, room_id, working, detail, timeout_seconds - ) - except ApiError as error: - if detail is None or error.status_code != HTTPStatus.UNPROCESSABLE_ENTITY: - raise - activity_support.disable(room_id=room_id, reason=error.body) - response = await MessageLifecycle._send_activity( - rest, room_id, working, None, timeout_seconds - ) - activity_support.observe(response.headers) - return response.data.data - - @staticmethod - async def _send_activity( - rest: AsyncRestClient, - room_id: str, - working: bool, - activity: AgentActivityDetail | None, - timeout_seconds: int, - ) -> AsyncHttpResponse[ReportAgentChatActivityResponse]: - kwargs: dict[str, Any] = {} - if activity is not None: - kwargs["activity"] = activity - return ( - await rest.agent_api_activity.with_raw_response.report_agent_chat_activity( - chat_id=room_id, - working=working, - request_options={ - **NO_RETRY_REQUEST_OPTIONS, - "timeout_in_seconds": timeout_seconds, - }, - **kwargs, - ) - ) - async def get_next_message( self, rest: AsyncRestClient, room_id: str ) -> PlatformMessage | None: diff --git a/tests/platform/activity/test_fallback.py b/tests/platform/activity/test_fallback.py index 648121f56..28313ee62 100644 --- a/tests/platform/activity/test_fallback.py +++ b/tests/platform/activity/test_fallback.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from unittest.mock import Mock import httpx @@ -34,8 +35,8 @@ async def test_422_retries_without_detail_and_latches_across_links( ] @pytest.mark.parametrize( - ("working", "activity"), - [(True, None), (False, AgentActivityDetail(**DETAIL))], + ("working", "with_detail"), + [(True, False), (False, True)], ids=["no-detail", "idle"], ) async def test_422_without_detail_neither_retries_nor_latches( @@ -45,13 +46,44 @@ async def test_422_without_detail_neither_retries_nor_latches( learned: ActivitySupport, detail: AgentActivityDetail, working: bool, - activity: AgentActivityDetail | None, + with_detail: bool, ) -> None: activity_peer.responses.append(httpx.Response(422)) + activity = detail if with_detail else None assert not await link.report_activity("room", working, activity=activity) assert len(activity_peer.reports) == 1 assert learned.supported + async def test_downgrade_logs_the_rejected_field_not_the_body( + self, + link: BandLink, + activity_peer: ActivityPeer, + detail: AgentActivityDetail, + caplog: pytest.LogCaptureFixture, + ) -> None: + activity_peer.responses.append( + httpx.Response( + 422, + json={ + "error": { + "code": "validation_error", + "details": {"/activity/name": ["echoed Bash --secret"]}, + "message": "Invalid request", + "request_id": "request-1", + } + }, + ) + ) + with caplog.at_level(logging.DEBUG, logger="band.platform"): + assert await link.report_activity("room", True, activity=detail) + [record] = [r for r in caplog.records if r.levelno == logging.INFO] + assert "/activity/name" in record.getMessage() + assert "secret" not in record.getMessage() + assert any( + r.levelno == logging.DEBUG and "secret" in r.getMessage() + for r in caplog.records + ) + @pytest.mark.parametrize("status", [400, 403, 500]) async def test_other_errors_do_not_retry_or_disable( self, From 3c665de606db0732812d0a80b20302dbaf80e08b Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 20 Oct 2026 18:08:55 +0300 Subject: [PATCH 16/33] refactor: Drop the unreachable cycle token and pin reporter guards _arm_false_report cancels the keep-alive before any replacement cycle can take the report lock, so the owner/cycle token never rejected anything. Names-only canonicalization and the kind constants live beside REASONING, detail is dropped from idle reports only in the lifecycle, and the reachable guards (settled scope, closing execution, unknown tool name, inactive reporter, detail reset across cycles) now each fail a test when removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb --- src/band/runtime/execution.py | 5 +- src/band/runtime/working_state.py | 45 ++++++++--------- tests/runtime/activityownership/kit.py | 15 +++++- .../runtime/activityownership/test_detail.py | 49 ++++++++++++++++++- tests/runtime/workingstate/test_detail.py | 47 ++++++++++++++++++ 5 files changed, 132 insertions(+), 29 deletions(-) diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index b91312fc1..5749404d9 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -1873,11 +1873,10 @@ async def _invoke_handler(self, event: PlatformEvent) -> None: async def _report_working_state(self, working: bool) -> bool: """Report the room's boolean working state (wired into the reporter).""" - detail = self._working_reporter.activity if working else None return await self.link.report_activity( self.room_id, working, - activity=detail, + activity=self._working_reporter.activity, timeout_seconds=self.config.working_request_timeout_seconds, on_stopped=partial( self._observe_activity_stop, @@ -1941,7 +1940,7 @@ async def _start_activity(self, scope: TurnScope) -> None: if self._activity_owner is not scope: self._activity_stop_scope = None self._activity_owner = scope - await self._working_reporter.start(owner=scope) + await self._working_reporter.start() def activity_observer(self, scope: TurnScope | None) -> ActivityObserver | None: return ScopeActivityObserver(self, scope) if scope is not None else None diff --git a/src/band/runtime/working_state.py b/src/band/runtime/working_state.py index c60fb4403..3638881e3 100644 --- a/src/band/runtime/working_state.py +++ b/src/band/runtime/working_state.py @@ -21,6 +21,8 @@ UNKNOWN_ACTIVITY_TOOL_NAME = "unknown" REASONING = AgentActivityDetail(kind="reasoning") +MEMORY = AgentActivityDetail(kind="memory") +PERMISSION = AgentActivityDetail(kind="permission") class ActivityObserver(Protocol): @@ -36,6 +38,13 @@ def tool_call_activity(name: str | None) -> AgentActivityDetail: ) +def names_only(activity: AgentActivityDetail) -> AgentActivityDetail: + """Keep only what the room may see: the kind and, for a tool, its name.""" + if activity.kind == "tool_call": + return tool_call_activity(activity.name) + return AgentActivityDetail(kind=activity.kind) + + def _observer(tools: object) -> ActivityObserver | None: return getattr(tools, "_activity_observer", None) @@ -52,7 +61,7 @@ async def end_tool_activity(tools: object) -> None: async def report_memory_activity(tools: object) -> None: if observer := _observer(tools): - await observer.update(AgentActivityDetail(kind="memory")) + await observer.update(MEMORY) async def set_permission_wait(tools: object, waiting: bool) -> None: @@ -86,7 +95,6 @@ def __init__( self._enabled = enabled self._active = False - self._cycle: object | None = None self._ordinary = REASONING self._manual_wait = False self._started_at: float | None = None @@ -100,13 +108,10 @@ def __init__( @property def activity(self) -> AgentActivityDetail: """The immutable effective detail, read by the bool callback under its lock.""" - return ( - AgentActivityDetail(kind="permission") - if self._manual_wait - else self._ordinary - ) + return PERMISSION if self._manual_wait else self._ordinary - async def start(self, *, owner: object | None = None) -> None: + async def start(self) -> None: + """Begin a cycle after the previous false; a re-entrant start is a no-op.""" if not self._enabled: return await self._finish_false_send() @@ -114,14 +119,12 @@ async def start(self, *, owner: object | None = None) -> None: if self._active: return self._active = True - cycle = owner if owner is not None else object() - self._cycle = cycle self._reset_detail() self._started_at = asyncio.get_running_loop().time() await self._report_locked(True) - if self._cycle is cycle and self._active: + if self._active: self._task = asyncio.create_task( - self._keep_alive(cycle), name="working-state-keepalive" + self._keep_alive(), name="working-state-keepalive" ) async def update( @@ -131,6 +134,7 @@ async def update( activity: AgentActivityDetail | None = None, waiting: bool | None = None, ) -> None: + """Apply an observation; ``current`` rechecks ownership under the lock.""" if not self._enabled: return async with self._lock: @@ -138,11 +142,7 @@ async def update( return previous = self.activity if activity is not None: - self._ordinary = ( - tool_call_activity(activity.name) - if activity.kind == "tool_call" - else AgentActivityDetail(kind=activity.kind) - ) + self._ordinary = names_only(activity) if waiting is not None: self._manual_wait = waiting if self.activity != previous: @@ -191,7 +191,6 @@ async def stop(self) -> None: def _arm_false_report(self) -> asyncio.Task[None] | None: self._active = False - self._cycle = None self._reset_detail() task, self._task = self._task, None if task is not None: @@ -223,7 +222,7 @@ async def _finish_false_send(self) -> None: if send.done(): self._false_send = None - async def _keep_alive(self, cycle: object) -> None: + async def _keep_alive(self) -> None: """Re-send ``working: true`` on cadence until stopped or capped.""" while True: await asyncio.sleep(self._keep_alive_seconds) @@ -239,7 +238,7 @@ async def _keep_alive(self, cycle: object) -> None: self._max_working_state_seconds, ) return - await self._safe_report(True, cycle=cycle) + await self._safe_report(True) def _cap_reached(self) -> bool: if self._max_working_state_seconds is None or self._started_at is None: @@ -247,11 +246,9 @@ def _cap_reached(self) -> bool: elapsed = asyncio.get_running_loop().time() - self._started_at return elapsed >= self._max_working_state_seconds - async def _safe_report(self, working: bool, *, cycle: object | None = None) -> None: + async def _safe_report(self, working: bool) -> None: async with self._lock: - if working and ( - not self._active or self._cycle is not cycle or self._cap_reached() - ): + if working and (not self._active or self._cap_reached()): return await self._report_locked(working) diff --git a/tests/runtime/activityownership/kit.py b/tests/runtime/activityownership/kit.py index bfec560b1..941a3822a 100644 --- a/tests/runtime/activityownership/kit.py +++ b/tests/runtime/activityownership/kit.py @@ -2,7 +2,7 @@ import asyncio import json -from typing import Any +from typing import Any, Literal import httpx @@ -36,3 +36,16 @@ def answer(self, request: httpx.Request) -> httpx.Response: 201, json={"data": {"id": "m-1", "success": True, "recipients": []}} ) return super().answer(request) + + +class ObservedIdle(asyncio.Event): + """The idle event a graceful stop waits on, announcing that wait.""" + + def __init__(self) -> None: + super().__init__() + self.set() + self.awaited = asyncio.Event() + + async def wait(self) -> Literal[True]: + self.awaited.set() + return await super().wait() diff --git a/tests/runtime/activityownership/test_detail.py b/tests/runtime/activityownership/test_detail.py index 11fa346c6..564a0298c 100644 --- a/tests/runtime/activityownership/test_detail.py +++ b/tests/runtime/activityownership/test_detail.py @@ -16,7 +16,7 @@ set_permission_wait, ) from tests.conftest import make_message_event -from tests.runtime.activityownership.kit import ActivityPlatform +from tests.runtime.activityownership.kit import ActivityPlatform, ObservedIdle from tests.runtime.helpers import ROOM_ID @@ -88,6 +88,53 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await end_tool_activity(captured[0]) assert activity_platform.activity_bodies == before + async def test_settled_turn_cannot_restart_reporting( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + captured: list[AgentTools] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + captured.append(AgentTools.from_context(ctx)) + + ctx = execution(handler) + await ctx._process_event(make_message_event()) + await ctx._join_activity_completion() + before = list(activity_platform.activity_bodies) + await report_tool_activity(captured[0], "Bash") + assert before[-1] == {"working": False} + assert activity_platform.activity_bodies == before + + async def test_graceful_stop_drops_observations_from_the_draining_turn( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + captured: list[AgentTools] = [] + handler_started = asyncio.Event() + release = asyncio.Event() + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + captured.append(AgentTools.from_context(ctx)) + handler_started.set() + await release.wait() + + ctx = execution(handler) + idle = ctx._idle_event = ObservedIdle() + await ctx.start() + await ctx.on_event(make_message_event()) + await handler_started.wait() + stopping = asyncio.create_task(ctx.stop(timeout=30)) + await idle.awaited.wait() + await report_tool_activity(captured[0], "Bash") + release.set() + assert await stopping + assert activity_platform.activity_bodies == [ + {"working": True, "activity": {"kind": "reasoning"}}, + {"working": False}, + ] + async def test_queued_observation_is_rechecked_after_stop_and_replacement( self, execution: Callable[..., ExecutionContext], diff --git a/tests/runtime/workingstate/test_detail.py b/tests/runtime/workingstate/test_detail.py index c4dd08167..6cecd58ec 100644 --- a/tests/runtime/workingstate/test_detail.py +++ b/tests/runtime/workingstate/test_detail.py @@ -140,3 +140,50 @@ async def report(working: bool) -> None: finally: await reporter.stop() assert reports == ["reasoning", "tool_call"] + + async def test_empty_tool_name_reports_unknown(self) -> None: + reports: list[dict[str, Any]] = [] + + async def report(working: bool) -> None: + if working: + reports.append(reporter.activity.model_dump(exclude_unset=True)) + + reporter = WorkingStateReporter(report) + try: + await reporter.start() + await reporter.update( + current=lambda: True, + activity=AgentActivityDetail(kind="tool_call", name=""), + ) + finally: + await reporter.stop() + assert reports[-1] == {"kind": "tool_call", "name": "unknown"} + + async def test_observation_outside_a_cycle_reports_nothing(self) -> None: + reports: list[bool] = [] + + async def report(working: bool) -> None: + reports.append(working) + + reporter = WorkingStateReporter(report) + await reporter.update( + current=lambda: True, + activity=AgentActivityDetail(kind="tool_call", name="Bash"), + ) + assert reports == [] + + async def test_permission_wait_does_not_carry_into_the_next_cycle(self) -> None: + reports: list[str | None] = [] + + async def report(working: bool) -> None: + reports.append(reporter.activity.kind if working else None) + + reporter = WorkingStateReporter(report) + try: + await reporter.start() + await reporter.update(current=lambda: True, waiting=True) + await reporter.stop() + await reporter.start() + finally: + await reporter.stop() + assert reports == ["reasoning", "permission", None, "reasoning", None] From 2c4a9f75244ba6aacaedc0c2300598dccbe4dc17 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 20 Oct 2026 18:08:55 +0300 Subject: [PATCH 17/33] test: Share one activity-support isolation helper Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb --- tests/activitysupport.py | 25 ++++++++++++++++++++++++ tests/platform/activity/conftest.py | 16 +++++++-------- tests/platform/activity/kit.py | 4 ++-- tests/platform/activity/test_versions.py | 14 +++---------- tests/runtime/conftest.py | 8 ++------ 5 files changed, 39 insertions(+), 28 deletions(-) create mode 100644 tests/activitysupport.py diff --git a/tests/activitysupport.py b/tests/activitysupport.py new file mode 100644 index 000000000..eeba99419 --- /dev/null +++ b/tests/activitysupport.py @@ -0,0 +1,25 @@ +"""Isolate the process-wide activity-detail negotiation per test.""" + +from __future__ import annotations + +import pytest + +import band.platform.message_lifecycle as lifecycle +from band.platform.activity import ( + API_VERSION_HEADER, + MINIMUM_ACTIVITY_VERSION, + ActivitySupport, +) + +SUPPORTED_VERSION = ".".join(map(str, MINIMUM_ACTIVITY_VERSION)) + + +def install_activity_support( + monkeypatch: pytest.MonkeyPatch, *, learned: bool +) -> ActivitySupport: + """Give the lifecycle a fresh negotiation, optionally already supported.""" + support = ActivitySupport() + if learned: + support.observe({API_VERSION_HEADER: SUPPORTED_VERSION}) + monkeypatch.setattr(lifecycle, "activity_support", support) + return support diff --git a/tests/platform/activity/conftest.py b/tests/platform/activity/conftest.py index 10b7390a4..f14f18e0e 100644 --- a/tests/platform/activity/conftest.py +++ b/tests/platform/activity/conftest.py @@ -5,18 +5,16 @@ import pytest from band_rest import AgentActivityDetail -import band.platform.message_lifecycle as lifecycle -from band.platform.activity import API_VERSION_HEADER, ActivitySupport +from band.platform.activity import ActivitySupport from band.platform.link import BandLink -from tests.platform.activity.kit import DETAIL, SUPPORTED_VERSION, ActivityPeer +from tests.activitysupport import install_activity_support +from tests.platform.activity.kit import DETAIL, ActivityPeer from tests.runtime.helpers import rest_client_over @pytest.fixture(autouse=True) def support(monkeypatch: pytest.MonkeyPatch) -> ActivitySupport: - support = ActivitySupport() - monkeypatch.setattr(lifecycle, "activity_support", support) - return support + return install_activity_support(monkeypatch, learned=False) @pytest.fixture @@ -31,6 +29,7 @@ def detail() -> AgentActivityDetail: @pytest.fixture async def link(activity_peer: ActivityPeer) -> AsyncIterator[BandLink]: + """A link on the SDK's default (SaaS) ``rest_url``.""" async with rest_client_over(activity_peer.respond) as rest: link = BandLink(agent_id="agent", api_key="test-key") link.rest = rest @@ -45,7 +44,6 @@ def second_link(link: BandLink) -> BandLink: @pytest.fixture -def learned(support: ActivitySupport) -> ActivitySupport: +def learned(monkeypatch: pytest.MonkeyPatch) -> ActivitySupport: """A process that already saw a supporting ``X-API-Version``.""" - support.observe({API_VERSION_HEADER: SUPPORTED_VERSION}) - return support + return install_activity_support(monkeypatch, learned=True) diff --git a/tests/platform/activity/kit.py b/tests/platform/activity/kit.py index c48168ca7..9eae53c17 100644 --- a/tests/platform/activity/kit.py +++ b/tests/platform/activity/kit.py @@ -7,9 +7,9 @@ import httpx -from band.platform.activity import API_VERSION_HEADER, MINIMUM_ACTIVITY_VERSION +from band.platform.activity import API_VERSION_HEADER +from tests.activitysupport import SUPPORTED_VERSION -SUPPORTED_VERSION = ".".join(map(str, MINIMUM_ACTIVITY_VERSION)) DETAIL = {"kind": "tool_call", "name": "Bash"} diff --git a/tests/platform/activity/test_versions.py b/tests/platform/activity/test_versions.py index c8bcf135f..4274f23d9 100644 --- a/tests/platform/activity/test_versions.py +++ b/tests/platform/activity/test_versions.py @@ -5,9 +5,9 @@ import pytest from band_rest import AgentActivityDetail -from band.config.settings import DEFAULT_REST_URL from band.platform.link import BandLink -from tests.platform.activity.kit import DETAIL, SUPPORTED_VERSION, ActivityPeer +from tests.activitysupport import SUPPORTED_VERSION +from tests.platform.activity.kit import DETAIL, ActivityPeer class TestActivityVersion: @@ -15,7 +15,7 @@ class TestActivityVersion: ("version", "supported"), [ ("1.24.99", False), - (SUPPORTED_VERSION, True), + pytest.param(SUPPORTED_VERSION, True, id="minimum-version-on-saas-default"), ("1.26.10", True), ("2.0.0", True), ("1.25.0-beta", False), @@ -40,14 +40,6 @@ async def test_advertised_version_controls_later_detail( {"working": True, "activity": DETAIL} if supported else {"working": True} ) assert activity_peer.reports == [{"working": True}, later] - - async def test_default_saas_url_learns_detail_from_activity_responses( - self, link: BandLink, activity_peer: ActivityPeer, detail: AgentActivityDetail - ) -> None: - assert link.rest_url == DEFAULT_REST_URL - assert await link.report_activity("room", True, activity=detail) - assert await link.report_activity("room", True, activity=detail) - assert activity_peer.reports[-1] == {"working": True, "activity": DETAIL} assert all( request.url.path.endswith("/activity") for request in activity_peer.requests ) diff --git a/tests/runtime/conftest.py b/tests/runtime/conftest.py index 4b723a5f0..f7457f7ed 100644 --- a/tests/runtime/conftest.py +++ b/tests/runtime/conftest.py @@ -32,12 +32,10 @@ ListAgentChatParticipantsResponse, ) -import band.platform.message_lifecycle as lifecycle from band.core.types import PlatformConnection, PlatformMessage -from band.platform.activity import API_VERSION_HEADER, ActivitySupport from band.runtime.presence import RoomPresence +from tests.activitysupport import install_activity_support from tests.conftest import make_participant_mock -from tests.platform.activity.kit import SUPPORTED_VERSION from tests.runtime.helpers import AGENT_ID @@ -267,6 +265,4 @@ def link(mock_rest_client: MagicMock) -> MagicMock: @pytest.fixture(autouse=True) def runtime_activity_support(monkeypatch: pytest.MonkeyPatch) -> None: """Start every runtime test on a platform that already advertised detail.""" - support = ActivitySupport() - support.observe({API_VERSION_HEADER: SUPPORTED_VERSION}) - monkeypatch.setattr(lifecycle, "activity_support", support) + install_activity_support(monkeypatch, learned=True) From 9d0953d59086a2b090a3ea71c83f092c6413ecf6 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 13:18:06 +0300 Subject: [PATCH 18/33] refactor: Keep activity detail on the turn and send it from the keep-alive Detail lived in a per-room reporter slot, so every write had to prove ownership under a lock and observations could claim it. Each turn now records its own detail; the room shows the live owner's detail. A visible change wakes the keep-alive, the single sender of working:true, so tools never wait on the network and changes during a report coalesce. A graceful stop now keeps the draining turn's detail current, and an observation never starts reporting. Delivery logs one line when support is learned and one when a 422 disables detail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb --- docs/turn-outcome.md | 10 +- src/band/platform/activity.py | 11 +- src/band/platform/message_lifecycle.py | 13 +- src/band/runtime/activity.py | 55 +++++ src/band/runtime/cycle.py | 2 + src/band/runtime/execution.py | 30 +-- src/band/runtime/tools/agent.py | 2 +- src/band/runtime/working_state.py | 158 ++++++--------- tests/platform/activity/kit.py | 8 + tests/platform/activity/test_fallback.py | 31 --- tests/platform/activity/test_reporting.py | 7 +- tests/platform/activity/test_versions.py | 15 -- tests/runtime/activity/test_turndetail.py | 38 ++++ tests/runtime/activityownership/kit.py | 27 ++- .../runtime/activityownership/test_detail.py | 120 +++++++---- tests/runtime/workingreporter/kit.py | 37 ++++ tests/runtime/workingreporter/test_refresh.py | 69 +++++++ .../runtime/workingreporter/test_startstop.py | 17 +- tests/runtime/workingstate/test_detail.py | 189 ------------------ 19 files changed, 428 insertions(+), 411 deletions(-) create mode 100644 src/band/runtime/activity.py create mode 100644 tests/runtime/activity/test_turndetail.py create mode 100644 tests/runtime/workingreporter/test_refresh.py delete mode 100644 tests/runtime/workingstate/test_detail.py diff --git a/docs/turn-outcome.md b/docs/turn-outcome.md index a665f07d0..e19b4d689 100644 --- a/docs/turn-outcome.md +++ b/docs/turn-outcome.md @@ -426,9 +426,13 @@ indicator while adapter residency retains responsibility for released workers. The authoritative idle report finishes before a replacement reports working. `SessionConfig.enable_working_state` controls both the indicator and activity -detail, independently of chat narration and tool capabilities. Changes and -keep-alives share one serialized reporter. The duration cap stops both kinds -of refresh without cancelling provider work; actual completion still clears. +detail, independently of chat narration and tool capabilities. Each turn keeps +its own detail, starting at reasoning; the room shows the owner's detail, and +none while an aborted owner unwinds. Reporting what a turn is doing never starts +reporting, changes ownership, or waits on the network: a visible change wakes +the reporter's keep-alive, which sends the latest detail, so changes made while +a report is in flight coalesce. The duration cap stops both kinds of refresh +without cancelling provider work; actual completion still clears. Detail compatibility is learned from the `X-API-Version` header the platform puts on every activity response. Reports stay boolean until a response in the diff --git a/src/band/platform/activity.py b/src/band/platform/activity.py index b9c9b6e3f..38e87b725 100644 --- a/src/band/platform/activity.py +++ b/src/band/platform/activity.py @@ -43,23 +43,22 @@ def supported(self) -> bool: def observe(self, headers: Mapping[str, str]) -> None: version = headers.get(API_VERSION_HEADER) if not _supports_detail(version): - logger.debug( - "Agent API version %r does not advertise activity detail", version - ) return with self._lock: + newly_learned = not self._learned self._learned = True + if newly_learned: + logger.info("Agent API %s supports activity detail", version) - def disable(self, *, room_id: str, field: str) -> None: + def disable(self, *, room_id: str) -> None: with self._lock: already_disabled = self._disabled self._disabled = True if not already_disabled: logger.info( - "Activity report with detail returned 422 in room %s (field %s); " + "Activity report with detail returned 422 in room %s; " "using boolean reporting for this process", room_id, - field, ) diff --git a/src/band/platform/message_lifecycle.py b/src/band/platform/message_lifecycle.py index cb17ad193..d99ce8032 100644 --- a/src/band/platform/message_lifecycle.py +++ b/src/band/platform/message_lifecycle.py @@ -116,14 +116,6 @@ def _failure_result(error: Exception) -> SettlementResult: return SettlementResult.UNCONFIRMED -def _first_rejected_field(body: object) -> str: - """A content-free cause: the first JSON pointer a validation error names.""" - envelope = _validated(ErrorEnvelope, body) - if envelope is None: - return "unparsed" - return next(iter(envelope.error.details), "unnamed") - - async def _post_activity( rest: AsyncRestClient, room_id: str, @@ -137,10 +129,7 @@ async def _post_activity( except ApiError as error: if detail is None or error.status_code != HTTPStatus.UNPROCESSABLE_ENTITY: raise - activity_support.disable( - room_id=room_id, field=_first_rejected_field(error.body) - ) - logger.debug("Activity 422 body for room %s: %r", room_id, error.body) + activity_support.disable(room_id=room_id) response = await _send_activity(rest, room_id, working, None, timeout_seconds) activity_support.observe(response.headers) return response.data.data diff --git a/src/band/runtime/activity.py b/src/band/runtime/activity.py new file mode 100644 index 000000000..d37ecfae9 --- /dev/null +++ b/src/band/runtime/activity.py @@ -0,0 +1,55 @@ +"""Names-only room activity detail and the per-turn state that holds it.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from band_rest.types.agent_activity_detail import AgentActivityDetail + +UNKNOWN_ACTIVITY_TOOL_NAME = "unknown" +TOOL_CALL = "tool_call" +REASONING = AgentActivityDetail(kind="reasoning") +MEMORY = AgentActivityDetail(kind="memory") +PERMISSION = AgentActivityDetail(kind="permission") + + +class ActivityObserver(Protocol): + async def update(self, activity: AgentActivityDetail) -> None: ... + + async def set_manual_wait(self, waiting: bool) -> None: ... + + +def tool_call_activity(name: str | None) -> AgentActivityDetail: + """The platform requires a name on every ``tool_call`` detail.""" + return AgentActivityDetail(kind=TOOL_CALL, name=name or UNKNOWN_ACTIVITY_TOOL_NAME) + + +def names_only(activity: AgentActivityDetail) -> AgentActivityDetail: + """Keep only what the room may see: the kind and, for a tool, its name.""" + if activity.kind == TOOL_CALL: + return tool_call_activity(activity.name) + return AgentActivityDetail(kind=activity.kind) + + +@dataclass +class TurnActivity: + """What one turn is doing; a person's wait overrides its progress.""" + + progress: AgentActivityDetail = REASONING + waiting: bool = False + + @property + def detail(self) -> AgentActivityDetail: + return PERMISSION if self.waiting else self.progress + + def apply( + self, + *, + activity: AgentActivityDetail | None = None, + waiting: bool | None = None, + ) -> None: + if activity is not None: + self.progress = names_only(activity) + if waiting is not None: + self.waiting = waiting diff --git a/src/band/runtime/cycle.py b/src/band/runtime/cycle.py index 6cc42de9f..44fdab4c6 100644 --- a/src/band/runtime/cycle.py +++ b/src/band/runtime/cycle.py @@ -7,6 +7,7 @@ from band.client.streaming import ControlMode from band.core.turn_lifecycle import TurnTask, terminal_deadline +from band.runtime.activity import TurnActivity @dataclass(eq=False) @@ -25,6 +26,7 @@ class TurnScope: shutdown: bool = False terminated: asyncio.Event = field(default_factory=asyncio.Event) workers: set[TurnTask] = field(default_factory=set) + activity: TurnActivity = field(default_factory=TurnActivity) @property def stop_observed(self) -> bool: diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index 5749404d9..2457f80c1 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -76,6 +76,7 @@ PlatformEvent, ReconnectedEvent, ) +from band.runtime.activity import ActivityObserver from band.runtime.callbacks import ParticipantAddedCallback, ParticipantRemovedCallback from band.runtime.context import ConversationContext from band.runtime.context_serialization import context_item_to_dict @@ -86,7 +87,7 @@ from band.runtime.resync import ResyncSchedule from band.runtime.sessionconfig import SessionConfig from band.runtime.tools.agent import AgentTools -from band.runtime.working_state import ActivityObserver, WorkingStateReporter +from band.runtime.working_state import WorkingStateReporter if TYPE_CHECKING: from band.platform.link import BandLink @@ -1876,7 +1877,7 @@ async def _report_working_state(self, working: bool) -> bool: return await self.link.report_activity( self.room_id, working, - activity=self._working_reporter.activity, + activity=self._visible_activity, timeout_seconds=self.config.working_request_timeout_seconds, on_stopped=partial( self._observe_activity_stop, @@ -1945,8 +1946,13 @@ async def _start_activity(self, scope: TurnScope) -> None: def activity_observer(self, scope: TurnScope | None) -> ActivityObserver | None: return ScopeActivityObserver(self, scope) if scope is not None else None - def owns_activity(self, scope: TurnScope) -> bool: - return self._activity_owner is scope and not scope.aborted and not self._closing + @property + def _visible_activity(self) -> AgentActivityDetail | None: + """The owner's detail; an aborted owner's stale work is never shown.""" + owner = self._activity_owner + if owner is None or owner.aborted: + return None + return owner.activity.detail async def observe_activity( self, @@ -1955,17 +1961,11 @@ async def observe_activity( activity: AgentActivityDetail | None = None, waiting: bool | None = None, ) -> None: - if ( - self._activity_owner is None - and scope is self.current_scope - and not scope.settled - ): - await self._acquire_activity(scope) - await self._working_reporter.update( - current=partial(self.owns_activity, scope), - activity=activity, - waiting=waiting, - ) + """Record a turn's activity; never starts reporting or waits on it.""" + before = self._visible_activity + scope.activity.apply(activity=activity, waiting=waiting) + if self._visible_activity != before: + self._working_reporter.refresh() def register_turn_task(self, scope: TurnScope, owner: TurnTask) -> None: """Bind completion to the same owner that delivers worker cancellation.""" diff --git a/src/band/runtime/tools/agent.py b/src/band/runtime/tools/agent.py index 823291c83..9067440a0 100644 --- a/src/band/runtime/tools/agent.py +++ b/src/band/runtime/tools/agent.py @@ -58,6 +58,7 @@ from band.core.types import Capability, MessageType from band.core.validation import at_least_one_of from band.platform.posting import post_event, post_message +from band.runtime.activity import ActivityObserver from band.runtime.capabilities import with_hub_room_contacts from band.runtime.context_serialization import context_item_to_dict from band.runtime.cycle import TurnScope @@ -74,7 +75,6 @@ validate_tool_arguments, ) from band.runtime.turn import records_turn_effects -from band.runtime.working_state import ActivityObserver if TYPE_CHECKING: from anthropic.types import ToolParam diff --git a/src/band/runtime/working_state.py b/src/band/runtime/working_state.py index 3638881e3..ea3d978b7 100644 --- a/src/band/runtime/working_state.py +++ b/src/band/runtime/working_state.py @@ -1,8 +1,30 @@ -"""Serialized working-state and names-only activity for one provider turn. - -The boolean callback must be time bounded. It reads immutable detail under the -same lock as changes and heartbeats. Terminal false is shielded; platform TTL -backstops failed reports. A duration cap stops refreshes without cancelling work. +"""Boolean working-state keep-alive reporter. + +Owns the per-room "is the agent working" signal for a single reasoning cycle: +sends ``working: true`` when a cycle starts, refreshes it on a keep-alive +cadence while the cycle is active, and sends an authoritative ``working: false`` +when the cycle ends. The platform applies a short TTL, so if refreshes stop for +any reason (crash, disconnect, loop-block) the indicator clears on its own — the +keep-alive is a *liveness* signal, not a correctness signal. + +The reporter is transport-agnostic: it is given an async ``report(working)`` +callback (wired to ``BandLink.report_activity`` by the runtime) and never raises +into the caller's reasoning loop. + +Contract: ``report`` MUST be time-bounded. The reporter awaits it (including the +authoritative false-send during teardown), so an unbounded callback could wedge +cycle teardown and cancellation propagation. ``BandLink.report_activity`` applies +a per-POST timeout to satisfy this. + +The keep-alive task is the only sender of ``working: true`` after the first. +``refresh()`` wakes it to report a changed activity now, so a caller never waits +on the network and changes made while a report is in flight coalesce. + +Cadence accuracy: the keep-alive waits ``keep_alive_seconds`` (or a wake) and +*then* reports, so the effective interval between successful refreshes is +``keep_alive_seconds + report-latency``. The platform-TTL headroom is preserved by +the ``cadence < TTL/2`` guard on ``SessionConfig`` (with the per-POST timeout kept +below the cadence), so even one missed/slow ping stays within the TTL window. """ from __future__ import annotations @@ -10,41 +32,20 @@ import asyncio import logging from collections.abc import Awaitable, Callable -from typing import Protocol +from contextlib import suppress -from band_rest.types.agent_activity_detail import AgentActivityDetail +from band.runtime.activity import ( + MEMORY, + REASONING, + ActivityObserver, + tool_call_activity, +) logger = logging.getLogger(__name__) ReportFn = Callable[[bool], Awaitable[object]] -UNKNOWN_ACTIVITY_TOOL_NAME = "unknown" -REASONING = AgentActivityDetail(kind="reasoning") -MEMORY = AgentActivityDetail(kind="memory") -PERMISSION = AgentActivityDetail(kind="permission") - - -class ActivityObserver(Protocol): - async def update(self, activity: AgentActivityDetail) -> None: ... - - async def set_manual_wait(self, waiting: bool) -> None: ... - - -def tool_call_activity(name: str | None) -> AgentActivityDetail: - """The platform requires a name on every ``tool_call`` detail.""" - return AgentActivityDetail( - kind="tool_call", name=name or UNKNOWN_ACTIVITY_TOOL_NAME - ) - - -def names_only(activity: AgentActivityDetail) -> AgentActivityDetail: - """Keep only what the room may see: the kind and, for a tool, its name.""" - if activity.kind == "tool_call": - return tool_call_activity(activity.name) - return AgentActivityDetail(kind=activity.kind) - - def _observer(tools: object) -> ActivityObserver | None: return getattr(tools, "_activity_observer", None) @@ -95,58 +96,35 @@ def __init__( self._enabled = enabled self._active = False - self._ordinary = REASONING - self._manual_wait = False self._started_at: float | None = None self._task: asyncio.Task[None] | None = None self._false_send: asyncio.Future[None] | None = None + self._wake = asyncio.Event() # Serializes every report() call (true / keep-alive / false) so they can # never overlap on the wire — guarantees the platform sees them in order # (boolean payload carries no sequence number to disambiguate). self._lock = asyncio.Lock() - @property - def activity(self) -> AgentActivityDetail: - """The immutable effective detail, read by the bool callback under its lock.""" - return PERMISSION if self._manual_wait else self._ordinary - async def start(self) -> None: - """Begin a cycle after the previous false; a re-entrant start is a no-op.""" - if not self._enabled: + """Begin a reasoning cycle: send ``working: true`` and arm keep-alive.""" + if not self._enabled or self._active: + # Re-entrant start while active is a no-op: the signal is already + # alive and the keep-alive task is already running. return await self._finish_false_send() - async with self._lock: - if self._active: - return - self._active = True - self._reset_detail() - self._started_at = asyncio.get_running_loop().time() - await self._report_locked(True) - if self._active: - self._task = asyncio.create_task( - self._keep_alive(), name="working-state-keepalive" - ) + self._active = True + self._wake.clear() + self._started_at = asyncio.get_running_loop().time() + await self._safe_report(True) + # A false armed during the first report ends the cycle before it ran. + if self._active: + self._task = asyncio.create_task( + self._keep_alive(), name="working-state-keepalive" + ) - async def update( - self, - *, - current: Callable[[], bool], - activity: AgentActivityDetail | None = None, - waiting: bool | None = None, - ) -> None: - """Apply an observation; ``current`` rechecks ownership under the lock.""" - if not self._enabled: - return - async with self._lock: - if not current() or not self._active or self._cap_reached(): - return - previous = self.activity - if activity is not None: - self._ordinary = names_only(activity) - if waiting is not None: - self._manual_wait = waiting - if self.activity != previous: - await self._report_locked(True) + def refresh(self) -> None: + """Report the current activity now instead of at the next keep-alive.""" + self._wake.set() def defer_false_report(self) -> None: """Start ``working: false`` and return without waiting for it. @@ -191,17 +169,12 @@ async def stop(self) -> None: def _arm_false_report(self) -> asyncio.Task[None] | None: self._active = False - self._reset_detail() task, self._task = self._task, None if task is not None: task.cancel() self._false_send = asyncio.ensure_future(self._safe_report(False)) return task - def _reset_detail(self) -> None: - self._ordinary = REASONING - self._manual_wait = False - async def _finish_false_send(self) -> None: send = self._false_send if send is None: @@ -223,9 +196,12 @@ async def _finish_false_send(self) -> None: self._false_send = None async def _keep_alive(self) -> None: - """Re-send ``working: true`` on cadence until stopped or capped.""" + """Re-send ``working: true`` on cadence or wake until stopped or capped.""" while True: - await asyncio.sleep(self._keep_alive_seconds) + with suppress(TimeoutError): + async with asyncio.timeout(self._keep_alive_seconds): + await self._wake.wait() + self._wake.clear() if not self._active: return if self._cap_reached(): @@ -247,17 +223,13 @@ def _cap_reached(self) -> bool: return elapsed >= self._max_working_state_seconds async def _safe_report(self, working: bool) -> None: + """Report under the serialization lock; never raise.""" async with self._lock: - if working and (not self._active or self._cap_reached()): - return - await self._report_locked(working) - - async def _report_locked(self, working: bool) -> None: - try: - await self._report(working) - except asyncio.CancelledError: - raise - except Exception: - logger.exception( - "working-state report failed (working=%s); swallowing", working - ) + try: + await self._report(working) + except asyncio.CancelledError: + raise + except Exception: + logger.exception( + "working-state report failed (working=%s); swallowing", working + ) diff --git a/tests/platform/activity/kit.py b/tests/platform/activity/kit.py index 9eae53c17..ffa6fe6cb 100644 --- a/tests/platform/activity/kit.py +++ b/tests/platform/activity/kit.py @@ -2,17 +2,25 @@ import asyncio import json +import logging from collections import deque from typing import Any import httpx +import pytest from band.platform.activity import API_VERSION_HEADER +from band.platform.message_lifecycle import logger as lifecycle_logger from tests.activitysupport import SUPPORTED_VERSION DETAIL = {"kind": "tool_call", "name": "Bash"} +def lifecycle_records(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecord]: + """Records of the delivery logger alone, not the version negotiation's.""" + return [r for r in caplog.records if r.name == lifecycle_logger.name] + + class ActivityPeer: """The activity endpoint, advertising ``version`` on every response.""" diff --git a/tests/platform/activity/test_fallback.py b/tests/platform/activity/test_fallback.py index 28313ee62..d07101e6a 100644 --- a/tests/platform/activity/test_fallback.py +++ b/tests/platform/activity/test_fallback.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging from unittest.mock import Mock import httpx @@ -54,36 +53,6 @@ async def test_422_without_detail_neither_retries_nor_latches( assert len(activity_peer.reports) == 1 assert learned.supported - async def test_downgrade_logs_the_rejected_field_not_the_body( - self, - link: BandLink, - activity_peer: ActivityPeer, - detail: AgentActivityDetail, - caplog: pytest.LogCaptureFixture, - ) -> None: - activity_peer.responses.append( - httpx.Response( - 422, - json={ - "error": { - "code": "validation_error", - "details": {"/activity/name": ["echoed Bash --secret"]}, - "message": "Invalid request", - "request_id": "request-1", - } - }, - ) - ) - with caplog.at_level(logging.DEBUG, logger="band.platform"): - assert await link.report_activity("room", True, activity=detail) - [record] = [r for r in caplog.records if r.levelno == logging.INFO] - assert "/activity/name" in record.getMessage() - assert "secret" not in record.getMessage() - assert any( - r.levelno == logging.DEBUG and "secret" in r.getMessage() - for r in caplog.records - ) - @pytest.mark.parametrize("status", [400, 403, 500]) async def test_other_errors_do_not_retry_or_disable( self, diff --git a/tests/platform/activity/test_reporting.py b/tests/platform/activity/test_reporting.py index bfc8bb51f..0c115d798 100644 --- a/tests/platform/activity/test_reporting.py +++ b/tests/platform/activity/test_reporting.py @@ -6,7 +6,7 @@ import pytest from band.platform.link import BandLink -from tests.platform.activity.kit import ActivityPeer +from tests.platform.activity.kit import ActivityPeer, lifecycle_records class TestActivityReporting: @@ -37,8 +37,9 @@ async def test_repeated_failures_warn_once_then_recover( assert not await link.report_activity("room-1", True) assert not await link.report_activity("room-1", True) assert await link.report_activity("room-1", True) - assert [record.levelno for record in caplog.records] == [ + records = lifecycle_records(caplog) + assert [record.levelno for record in records] == [ logging.WARNING, logging.INFO, ] - assert "recovered" in caplog.records[-1].getMessage() + assert "recovered" in records[-1].getMessage() diff --git a/tests/platform/activity/test_versions.py b/tests/platform/activity/test_versions.py index 4274f23d9..bb7a4bab6 100644 --- a/tests/platform/activity/test_versions.py +++ b/tests/platform/activity/test_versions.py @@ -1,7 +1,5 @@ from __future__ import annotations -import logging - import pytest from band_rest import AgentActivityDetail @@ -67,16 +65,3 @@ async def test_non_tool_detail_omits_name_and_false_omits_detail( {"working": True, "activity": {"kind": kind}}, {"working": False}, ] - - async def test_unadvertised_version_is_logged_for_diagnosis( - self, - link: BandLink, - activity_peer: ActivityPeer, - caplog: pytest.LogCaptureFixture, - ) -> None: - activity_peer.version = "1.24.0" - with caplog.at_level(logging.DEBUG, logger="band.platform.activity"): - assert await link.report_activity("room", True) - [record] = caplog.records - assert record.levelno == logging.DEBUG - assert "'1.24.0'" in record.getMessage() diff --git a/tests/runtime/activity/test_turndetail.py b/tests/runtime/activity/test_turndetail.py new file mode 100644 index 000000000..fc2c39a19 --- /dev/null +++ b/tests/runtime/activity/test_turndetail.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from band_rest import AgentActivityDetail + +from band.runtime.activity import TurnActivity + + +class TestTurnActivity: + def test_a_turn_starts_at_reasoning(self) -> None: + assert TurnActivity().detail == AgentActivityDetail(kind="reasoning") + + def test_only_the_kind_and_tool_name_are_kept(self) -> None: + activity = TurnActivity() + activity.apply( + activity=AgentActivityDetail( + kind="tool_call", name="Bash", arguments="private" + ) + ) + assert activity.detail.model_dump(exclude_unset=True) == { + "kind": "tool_call", + "name": "Bash", + } + activity.apply(activity=AgentActivityDetail(kind="memory", name="private")) + assert activity.detail.model_dump(exclude_unset=True) == {"kind": "memory"} + + def test_a_nameless_tool_is_reported_as_unknown(self) -> None: + activity = TurnActivity() + activity.apply(activity=AgentActivityDetail(kind="tool_call", name="")) + assert activity.detail == AgentActivityDetail(kind="tool_call", name="unknown") + + def test_a_person_wait_overrides_progress_without_losing_it(self) -> None: + activity = TurnActivity() + activity.apply(activity=AgentActivityDetail(kind="tool_call", name="Read")) + activity.apply(waiting=True) + activity.apply(activity=AgentActivityDetail(kind="memory")) + assert activity.detail == AgentActivityDetail(kind="permission") + activity.apply(waiting=False) + assert activity.detail == AgentActivityDetail(kind="memory") diff --git a/tests/runtime/activityownership/kit.py b/tests/runtime/activityownership/kit.py index 941a3822a..a8b24a20a 100644 --- a/tests/runtime/activityownership/kit.py +++ b/tests/runtime/activityownership/kit.py @@ -2,6 +2,7 @@ import asyncio import json +from collections.abc import Awaitable from typing import Any, Literal import httpx @@ -16,19 +17,33 @@ def __init__(self) -> None: self.hold_next_activity = False self.activity_entered = asyncio.Event() self.activity_release = asyncio.Event() + self._reported = asyncio.Condition() async def respond(self, request: httpx.Request) -> httpx.Response: response = self.answer(request) - if self.hold_next_activity and request.url.path.endswith("/activity"): - self.hold_next_activity = False - self.activity_entered.set() - await self.activity_release.wait() + if request.url.path.endswith("/activity"): + async with self._reported: + self.activity_bodies.append(json.loads(request.content)) + self._reported.notify_all() + if self.hold_next_activity: + self.hold_next_activity = False + self.activity_entered.set() + await self.activity_release.wait() return response + async def bodies_reach(self, count: int) -> list[dict[str, Any]]: + async with self._reported: + await self._reported.wait_for(lambda: len(self.activity_bodies) >= count) + return self.activity_bodies + + async def shown(self, observation: Awaitable[None]) -> None: + """Make a visible change and wait until the platform has received it.""" + count = len(self.activity_bodies) + await observation + await self.bodies_reach(count + 1) + def answer(self, request: httpx.Request) -> httpx.Response: tail = request.url.path.rsplit("/", 1)[-1] - if tail == "activity": - self.activity_bodies.append(json.loads(request.content)) if tail == "participants": return page([{**ALICE, "role": "member", "status": "active"}]) if request.method == "POST" and tail == "messages": diff --git a/tests/runtime/activityownership/test_detail.py b/tests/runtime/activityownership/test_detail.py index 564a0298c..75aa2009b 100644 --- a/tests/runtime/activityownership/test_detail.py +++ b/tests/runtime/activityownership/test_detail.py @@ -28,12 +28,13 @@ async def test_real_tools_observer_reports_canonical_wire_detail( ) -> None: async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: tools = AgentTools.from_context(ctx) - await report_tool_activity(tools, "Bash") - await set_permission_wait(tools, True) + await activity_platform.shown(report_tool_activity(tools, "Bash")) + await activity_platform.shown(set_permission_wait(tools, True)) + # Hidden behind the wait: the room keeps seeing permission. await end_tool_activity(tools) await report_memory_activity(tools) - await set_permission_wait(tools, False) - await end_tool_activity(tools) + await activity_platform.shown(set_permission_wait(tools, False)) + await activity_platform.shown(end_tool_activity(tools)) ctx = execution(handler) await ctx._process_event(make_message_event()) @@ -74,7 +75,7 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: == [{"working": True, "activity": {"kind": "permission"}}] * 2 ) await report_tool_activity(captured[0], "Bash") - await set_permission_wait(captured[0], False) + await activity_platform.shown(set_permission_wait(captured[0], False)) assert activity_platform.activity_bodies[-1] == { "working": True, "activity": {"kind": "tool_call", "name": "Bash"}, @@ -106,7 +107,7 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: assert before[-1] == {"working": False} assert activity_platform.activity_bodies == before - async def test_graceful_stop_drops_observations_from_the_draining_turn( + async def test_graceful_stop_keeps_the_draining_turn_detail_current( self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform, @@ -127,52 +128,51 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await handler_started.wait() stopping = asyncio.create_task(ctx.stop(timeout=30)) await idle.awaited.wait() - await report_tool_activity(captured[0], "Bash") + await activity_platform.shown(report_tool_activity(captured[0], "Bash")) release.set() assert await stopping assert activity_platform.activity_bodies == [ {"working": True, "activity": {"kind": "reasoning"}}, + {"working": True, "activity": {"kind": "tool_call", "name": "Bash"}}, {"working": False}, ] - async def test_queued_observation_is_rechecked_after_stop_and_replacement( + @pytest.mark.looptime + async def test_an_interrupted_turn_cannot_change_detail_while_unwinding( self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform, ) -> None: - captured: list[AgentTools] = [] - handler_started = asyncio.Event() - release = asyncio.Event() + cleanup_entered = asyncio.Event() + cleanup_release = asyncio.Event() async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: - captured.append(AgentTools.from_context(ctx)) - handler_started.set() - await release.wait() + tools = AgentTools.from_context(ctx) + await activity_platform.shown(report_tool_activity(tools, "Bash")) + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + await report_memory_activity(tools) + cleanup_entered.set() + await cleanup_release.wait() + raise ctx = execution(handler) - turn = asyncio.create_task(ctx._process_event(make_message_event(msg_id="old"))) - await handler_started.wait() - activity_platform.hold_next_activity = True - change = asyncio.create_task(report_tool_activity(captured[0], "Bash")) - await activity_platform.activity_entered.wait() - stale = asyncio.create_task(report_memory_activity(captured[0])) + turn = asyncio.create_task(ctx._process_event(make_message_event())) + await activity_platform.bodies_reach(2) ctx.interrupt() - activity_platform.activity_release.set() - await asyncio.gather(turn, change, stale) - release.set() - await ctx._process_event(make_message_event(msg_id="new")) - assert activity_platform.activity_bodies[-3:] == [ - {"working": False}, + await cleanup_entered.wait() + # Less than the cleanup grace and the keep-alive: only a change could post. + await asyncio.sleep(0.5) + cleanup_release.set() + await turn + assert activity_platform.activity_bodies == [ {"working": True, "activity": {"kind": "reasoning"}}, + {"working": True, "activity": {"kind": "tool_call", "name": "Bash"}}, {"working": False}, ] - assert not any( - body.get("activity", {}).get("kind") == "memory" - for body in activity_platform.activity_bodies - ) - @pytest.mark.looptime - async def test_waiting_delivery_takes_over_reporting_once_the_owner_finishes( + async def test_observation_from_a_waiting_delivery_never_starts_reporting( self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform, @@ -203,14 +203,62 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: worker_release.set() await workers[0].task await ctx._join_activity_completion() + before = list(activity_platform.activity_bodies) await report_tool_activity(waiting_tools[0], "Bash") - assert activity_platform.activity_bodies[-3:] == [ - {"working": False}, + assert before[-1] == {"working": False} + assert activity_platform.activity_bodies == before + waiting_release.set() + await waiting + + async def test_a_new_turn_starts_at_reasoning( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + turns: list[ExecutionContext] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + turns.append(ctx) + if len(turns) > 1: + return + tools = AgentTools.from_context(ctx) + await activity_platform.shown(report_tool_activity(tools, "Bash")) + await activity_platform.shown(set_permission_wait(tools, True)) + + ctx = execution(handler) + await ctx._process_event(make_message_event(msg_id="first")) + await ctx._process_event(make_message_event(msg_id="second")) + assert activity_platform.activity_bodies == [ {"working": True, "activity": {"kind": "reasoning"}}, {"working": True, "activity": {"kind": "tool_call", "name": "Bash"}}, + {"working": True, "activity": {"kind": "permission"}}, + {"working": False}, + {"working": True, "activity": {"kind": "reasoning"}}, + {"working": False}, ] - waiting_release.set() - await waiting + + async def test_a_tool_never_waits_for_the_platform( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + observed_before_delivery: list[bool] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + activity_platform.hold_next_activity = True + observing = asyncio.create_task(report_tool_activity(tools, "Bash")) + await activity_platform.activity_entered.wait() + observed_before_delivery.append(observing.done()) + activity_platform.activity_release.set() + + ctx = execution(handler) + await ctx._process_event(make_message_event()) + assert observed_before_delivery == [True] + assert activity_platform.activity_bodies[1] == { + "working": True, + "activity": {"kind": "tool_call", "name": "Bash"}, + } @pytest.mark.parametrize("excluded", ["disabled", "hub"]) async def test_excluded_reporting_ignores_observations( diff --git a/tests/runtime/workingreporter/kit.py b/tests/runtime/workingreporter/kit.py index ee553bf4c..34d0d2b73 100644 --- a/tests/runtime/workingreporter/kit.py +++ b/tests/runtime/workingreporter/kit.py @@ -26,3 +26,40 @@ async def __call__(self, working: bool) -> bool: if self.fail: raise RuntimeError("report failed") return True + + +class DetailRecorder: + """Report callback recording the detail a report would carry when it starts. + + ``hold()`` parks the next report after recording it, until ``release()``. + """ + + def __init__(self) -> None: + self.detail = "reasoning" + self.reports: list[str | None] = [] + self.entered = asyncio.Event() + self._gate: asyncio.Event | None = None + self._changed = asyncio.Condition() + + def hold(self) -> None: + self._gate = asyncio.Event() + + def release(self) -> None: + if self._gate is not None: + self._gate.set() + + async def __call__(self, working: bool) -> bool: + async with self._changed: + self.reports.append(self.detail if working else None) + self._changed.notify_all() + gate = self._gate + if gate is not None: + self.entered.set() + await gate.wait() + self._gate = None + return True + + async def reports_reach(self, count: int) -> list[str | None]: + async with self._changed: + await self._changed.wait_for(lambda: len(self.reports) >= count) + return self.reports diff --git a/tests/runtime/workingreporter/test_refresh.py b/tests/runtime/workingreporter/test_refresh.py new file mode 100644 index 000000000..371f3228d --- /dev/null +++ b/tests/runtime/workingreporter/test_refresh.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from band.runtime.working_state import WorkingStateReporter +from tests.runtime.workingreporter.kit import DetailRecorder + +# Longer than any test, so every report after start is a refresh. +NO_KEEP_ALIVE = 3600.0 + + +class TestRefresh: + async def test_a_change_is_reported_without_waiting_for_the_keep_alive( + self, + ) -> None: + recorder = DetailRecorder() + reporter = WorkingStateReporter(recorder, keep_alive_seconds=NO_KEEP_ALIVE) + await reporter.start() + recorder.detail = "Bash" + reporter.refresh() + assert await recorder.reports_reach(2) == ["reasoning", "Bash"] + await reporter.stop() + assert recorder.reports == ["reasoning", "Bash", None] + + async def test_a_change_during_the_first_report_follows_it(self) -> None: + recorder = DetailRecorder() + reporter = WorkingStateReporter(recorder, keep_alive_seconds=NO_KEEP_ALIVE) + recorder.hold() + starting = asyncio.create_task(reporter.start()) + await recorder.entered.wait() + recorder.detail = "Bash" + reporter.refresh() + recorder.release() + await starting + assert await recorder.reports_reach(2) == ["reasoning", "Bash"] + await reporter.stop() + + async def test_changes_during_a_report_coalesce_to_the_latest(self) -> None: + recorder = DetailRecorder() + reporter = WorkingStateReporter(recorder, keep_alive_seconds=NO_KEEP_ALIVE) + await reporter.start() + recorder.hold() + recorder.detail = "Read" + reporter.refresh() + await recorder.entered.wait() + recorder.detail = "Bash" + reporter.refresh() + recorder.detail = "memory" + reporter.refresh() + recorder.release() + assert await recorder.reports_reach(3) == ["reasoning", "Read", "memory"] + await reporter.stop() + assert recorder.reports == ["reasoning", "Read", "memory", None] + + @pytest.mark.looptime + async def test_a_change_past_the_cap_is_not_reported(self) -> None: + recorder = DetailRecorder() + reporter = WorkingStateReporter( + recorder, keep_alive_seconds=NO_KEEP_ALIVE, max_working_state_seconds=2 + ) + await reporter.start() + await asyncio.sleep(2.1) + recorder.detail = "Bash" + reporter.refresh() + await asyncio.sleep(1) + await reporter.stop() + assert recorder.reports == ["reasoning", None] diff --git a/tests/runtime/workingreporter/test_startstop.py b/tests/runtime/workingreporter/test_startstop.py index 3d87f3424..a7512f8bd 100644 --- a/tests/runtime/workingreporter/test_startstop.py +++ b/tests/runtime/workingreporter/test_startstop.py @@ -5,7 +5,7 @@ import pytest from band.runtime.working_state import WorkingStateReporter -from tests.runtime.workingreporter.kit import Recorder +from tests.runtime.workingreporter.kit import DetailRecorder, Recorder class TestStartStop: @@ -50,3 +50,18 @@ async def test_disabled_reports_nothing(self): await reporter.stop() assert rec.calls == [] + + @pytest.mark.looptime + async def test_stop_during_the_first_report_leaves_no_keep_alive(self): + recorder = DetailRecorder() + reporter = WorkingStateReporter(recorder, keep_alive_seconds=3.0) + recorder.hold() + starting = asyncio.create_task(reporter.start()) + await recorder.entered.wait() + stopping = asyncio.create_task(reporter.stop()) + recorder.release() + await asyncio.gather(starting, stopping) + await reporter.start() + await asyncio.sleep(3.1) + await reporter.stop() + assert recorder.reports == ["reasoning", None, "reasoning", "reasoning", None] diff --git a/tests/runtime/workingstate/test_detail.py b/tests/runtime/workingstate/test_detail.py deleted file mode 100644 index 6cecd58ec..000000000 --- a/tests/runtime/workingstate/test_detail.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import Any - -import pytest -from band_rest import AgentActivityDetail - -from band.runtime.working_state import WorkingStateReporter - - -@pytest.mark.looptime -class TestActivityDetail: - async def test_changes_repeat_current_detail_and_keep_boolean_callback( - self, - ) -> None: - reports: list[tuple[bool, dict[str, Any] | None]] = [] - - async def report(working: bool) -> None: - reports.append( - ( - working, - reporter.activity.model_dump(exclude_unset=True) - if working - else None, - ) - ) - - reporter = WorkingStateReporter(report) - try: - await reporter.start() - await reporter.update( - current=lambda: True, - activity=AgentActivityDetail( - kind="tool_call", name="Bash", arguments="private" - ), - ) - await reporter.update( - current=lambda: True, - activity=AgentActivityDetail(kind="tool_call", name="Bash"), - ) - await asyncio.sleep(3.1) - await reporter.update( - current=lambda: True, - activity=AgentActivityDetail( - kind="reasoning", name="private", output="private" - ), - ) - finally: - await reporter.stop() - assert reports == [ - (True, {"kind": "reasoning"}), - (True, {"kind": "tool_call", "name": "Bash"}), - (True, {"kind": "tool_call", "name": "Bash"}), - (True, {"kind": "reasoning"}), - (False, None), - ] - - async def test_manual_wait_overrides_latest_progress_without_restoring_stale_state( - self, - ) -> None: - reports: list[str] = [] - - async def report(working: bool) -> None: - if working: - reports.append(reporter.activity.kind) - - reporter = WorkingStateReporter(report) - try: - await reporter.start() - await reporter.update( - current=lambda: True, - activity=AgentActivityDetail(kind="tool_call", name="Read"), - ) - await reporter.update(current=lambda: True, waiting=True) - await reporter.update( - current=lambda: True, activity=AgentActivityDetail(kind="reasoning") - ) - await reporter.update(current=lambda: True, waiting=True) - await reporter.update( - current=lambda: True, activity=AgentActivityDetail(kind="memory") - ) - await reporter.update(current=lambda: True, waiting=False) - finally: - await reporter.stop() - assert reports == ["reasoning", "tool_call", "permission", "memory"] - - async def test_cap_prevents_detail_from_reviving_the_indicator(self) -> None: - reports: list[bool] = [] - - async def report(working: bool) -> None: - reports.append(working) - - reporter = WorkingStateReporter(report, max_working_state_seconds=2) - try: - await reporter.start() - await asyncio.sleep(2.1) - await reporter.update( - current=lambda: True, - activity=AgentActivityDetail(kind="tool_call", name="Bash"), - ) - await asyncio.sleep(3) - assert reports == [True] - finally: - await reporter.stop() - assert reports == [True, False] - - async def test_observation_queued_behind_a_report_is_rechecked_under_the_lock( - self, - ) -> None: - entered, release = asyncio.Event(), asyncio.Event() - reports: list[str] = [] - - async def report(working: bool) -> None: - if working: - reports.append(reporter.activity.kind) - if reporter.activity.kind == "tool_call": - entered.set() - await release.wait() - - reporter = WorkingStateReporter(report) - owned = True - try: - await reporter.start() - change = asyncio.create_task( - reporter.update( - current=lambda: owned, - activity=AgentActivityDetail(kind="tool_call", name="Bash"), - ) - ) - await entered.wait() - stale = asyncio.create_task( - reporter.update( - current=lambda: owned, activity=AgentActivityDetail(kind="memory") - ) - ) - owned = False - release.set() - await asyncio.gather(change, stale) - finally: - await reporter.stop() - assert reports == ["reasoning", "tool_call"] - - async def test_empty_tool_name_reports_unknown(self) -> None: - reports: list[dict[str, Any]] = [] - - async def report(working: bool) -> None: - if working: - reports.append(reporter.activity.model_dump(exclude_unset=True)) - - reporter = WorkingStateReporter(report) - try: - await reporter.start() - await reporter.update( - current=lambda: True, - activity=AgentActivityDetail(kind="tool_call", name=""), - ) - finally: - await reporter.stop() - assert reports[-1] == {"kind": "tool_call", "name": "unknown"} - - async def test_observation_outside_a_cycle_reports_nothing(self) -> None: - reports: list[bool] = [] - - async def report(working: bool) -> None: - reports.append(working) - - reporter = WorkingStateReporter(report) - await reporter.update( - current=lambda: True, - activity=AgentActivityDetail(kind="tool_call", name="Bash"), - ) - assert reports == [] - - async def test_permission_wait_does_not_carry_into_the_next_cycle(self) -> None: - reports: list[str | None] = [] - - async def report(working: bool) -> None: - reports.append(reporter.activity.kind if working else None) - - reporter = WorkingStateReporter(report) - try: - await reporter.start() - await reporter.update(current=lambda: True, waiting=True) - await reporter.stop() - await reporter.start() - finally: - await reporter.stop() - assert reports == ["reasoning", "permission", None, "reasoning", None] From 6860082d144a59e13bce422f0f89c0247a89a5d9 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 13:43:35 +0300 Subject: [PATCH 19/33] refactor: Return early for a missing platform version Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- src/band/platform/activity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/band/platform/activity.py b/src/band/platform/activity.py index 38e87b725..41a57b0f5 100644 --- a/src/band/platform/activity.py +++ b/src/band/platform/activity.py @@ -20,7 +20,9 @@ def _supports_detail(version: str | None) -> bool: - match = RELEASE_VERSION.fullmatch(version) if version is not None else None + if version is None: + return False + match = RELEASE_VERSION.fullmatch(version) return ( match is not None and tuple(map(int, match.groups())) >= MINIMUM_ACTIVITY_VERSION From 1faf28b8c3103edd1328dc1b133e399c0d90130b Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 14:04:49 +0300 Subject: [PATCH 20/33] docs: Describe activity detail and the per-report deadline in reporter docs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- src/band/platform/message_lifecycle.py | 2 +- src/band/runtime/execution.py | 4 ++-- src/band/runtime/working_state.py | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/band/platform/message_lifecycle.py b/src/band/platform/message_lifecycle.py index d99ce8032..a2b55bdeb 100644 --- a/src/band/platform/message_lifecycle.py +++ b/src/band/platform/message_lifecycle.py @@ -358,7 +358,7 @@ async def report_activity( on_stopped: Callable[[], None] | None = None, ) -> bool: """ - Report the agent's boolean working state for a room's execution. + Report the agent's working state and activity for a room's execution. ``working=True`` while a reasoning cycle is active (refreshed on a keep-alive cadence), ``False`` once it ends. Request failures are diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index 2457f80c1..d64d3827b 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -365,7 +365,7 @@ def __init__( self._on_participant_removed = on_participant_removed self.hub_room_id = hub_room_id - # Per-room boolean working-state reporter. Disabled for the contact-hub + # Per-room working-state reporter. Disabled for the contact-hub # room: it's internal housekeeping, not a peer-facing conversation, so no # counterpart watches a "Reasoning…" indicator there. self._working_reporter = WorkingStateReporter( @@ -1873,7 +1873,7 @@ async def _invoke_handler(self, event: PlatformEvent) -> None: await self._on_execute(self, event) async def _report_working_state(self, working: bool) -> bool: - """Report the room's boolean working state (wired into the reporter).""" + """Report working state and visible activity (wired into the reporter).""" return await self.link.report_activity( self.room_id, working, diff --git a/src/band/runtime/working_state.py b/src/band/runtime/working_state.py index ea3d978b7..7ca25a809 100644 --- a/src/band/runtime/working_state.py +++ b/src/band/runtime/working_state.py @@ -1,4 +1,4 @@ -"""Boolean working-state keep-alive reporter. +"""Working-state keep-alive reporter. Owns the per-room "is the agent working" signal for a single reasoning cycle: sends ``working: true`` when a cycle starts, refreshes it on a keep-alive @@ -14,7 +14,7 @@ Contract: ``report`` MUST be time-bounded. The reporter awaits it (including the authoritative false-send during teardown), so an unbounded callback could wedge cycle teardown and cancellation propagation. ``BandLink.report_activity`` applies -a per-POST timeout to satisfy this. +one deadline per report, covering the POST and its compatibility resend. The keep-alive task is the only sender of ``working: true`` after the first. ``refresh()`` wakes it to report a changed activity now, so a caller never waits @@ -23,7 +23,7 @@ Cadence accuracy: the keep-alive waits ``keep_alive_seconds`` (or a wake) and *then* reports, so the effective interval between successful refreshes is ``keep_alive_seconds + report-latency``. The platform-TTL headroom is preserved by -the ``cadence < TTL/2`` guard on ``SessionConfig`` (with the per-POST timeout kept +the ``cadence < TTL/2`` guard on ``SessionConfig`` (with the report deadline kept below the cadence), so even one missed/slow ping stays within the TTL window. """ @@ -76,7 +76,7 @@ def _retrieve_task_error(task: asyncio.Task[None]) -> None: class WorkingStateReporter: - """Manages the boolean working signal for one execution (agent + room).""" + """Manages the working signal and its activity for one execution (agent + room).""" def __init__( self, @@ -186,7 +186,7 @@ async def _finish_false_send(self) -> None: # platform clears the indicator immediately rather than via TTL. # Accepted gap: a *second* cancel landing during this await can abandon # it, leaving `send` detached. `send` is time-bounded (the report - # callback has a per-POST deadline) so it still completes on the loop; + # callback has a per-report deadline) so it still completes on the loop; # and the platform TTL backstops the clear regardless. Not handled # explicitly — the double-cancel teardown window is vanishingly rare. await send From 978c0ac129c137af37e6c82a85a0304dab3fe2bb Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 14:04:49 +0300 Subject: [PATCH 21/33] test: Split the activity deadline test and pin the exact deadline Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- tests/platform/activity/test_deadlines.py | 33 ++++++++++++++----- .../runtime/workingreporter/test_startstop.py | 2 +- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/tests/platform/activity/test_deadlines.py b/tests/platform/activity/test_deadlines.py index 330c5e25c..7f6ceafe4 100644 --- a/tests/platform/activity/test_deadlines.py +++ b/tests/platform/activity/test_deadlines.py @@ -9,24 +9,39 @@ from band.platform.link import BandLink from tests.platform.activity.kit import DETAIL, ActivityPeer +DEADLINE_SECONDS = 2 + + +@pytest.fixture +def stalled_resend(activity_peer: ActivityPeer) -> ActivityPeer: + """The detail POST is rejected and its detail-free resend never answers.""" + activity_peer.responses.append(httpx.Response(422)) + activity_peer.stall_post_number = 2 + return activity_peer + @pytest.mark.looptime @pytest.mark.usefixtures("learned") class TestActivityDeadline: - async def test_retry_cannot_extend_total_deadline_and_downgrade_survives_timeout( - self, link: BandLink, activity_peer: ActivityPeer, detail: AgentActivityDetail + async def test_resend_cannot_extend_the_report_deadline( + self, link: BandLink, stalled_resend: ActivityPeer, detail: AgentActivityDetail ) -> None: - activity_peer.responses.append(httpx.Response(422)) - activity_peer.stall_post_number = 2 started = asyncio.get_running_loop().time() assert not await link.report_activity( - "room", True, activity=detail, timeout_seconds=2 + "room", True, activity=detail, timeout_seconds=DEADLINE_SECONDS ) - assert asyncio.get_running_loop().time() - started <= 2.01 - assert activity_peer.reports == [ + assert asyncio.get_running_loop().time() - started == DEADLINE_SECONDS + assert stalled_resend.reports == [ {"working": True, "activity": DETAIL}, {"working": True}, ] - activity_peer.stall_post_number = None + + async def test_downgrade_survives_a_timed_out_resend( + self, link: BandLink, stalled_resend: ActivityPeer, detail: AgentActivityDetail + ) -> None: + await link.report_activity( + "room", True, activity=detail, timeout_seconds=DEADLINE_SECONDS + ) + stalled_resend.stall_post_number = None assert await link.report_activity("next", True, activity=detail) - assert activity_peer.reports[-1] == {"working": True} + assert stalled_resend.reports[-1] == {"working": True} diff --git a/tests/runtime/workingreporter/test_startstop.py b/tests/runtime/workingreporter/test_startstop.py index a7512f8bd..86dbc080c 100644 --- a/tests/runtime/workingreporter/test_startstop.py +++ b/tests/runtime/workingreporter/test_startstop.py @@ -52,7 +52,7 @@ async def test_disabled_reports_nothing(self): assert rec.calls == [] @pytest.mark.looptime - async def test_stop_during_the_first_report_leaves_no_keep_alive(self): + async def test_stop_during_the_first_report_leaves_no_keep_alive(self) -> None: recorder = DetailRecorder() reporter = WorkingStateReporter(recorder, keep_alive_seconds=3.0) recorder.hold() From dfd81ca7c904edd35554673fc4b40bcf9758106f Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 14:55:36 +0300 Subject: [PATCH 22/33] test: Prove activity detail through realistic turn flows Run exact-body activity tests on a controlled clock, pin the shared report deadline against a slow rejection, and cover a fresh process learning detail mid-turn, a mid-turn 422 latch, interrupt-then-new-message, a STOP echoed on a detail refresh, the working-state cap through ExecutionContext, and the producer helpers on tools without an observer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- tests/platform/activity/kit.py | 8 +- tests/platform/activity/test_deadlines.py | 5 +- tests/runtime/activityownership/conftest.py | 11 +++ tests/runtime/activityownership/kit.py | 21 ++++ tests/runtime/activityownership/test_cap.py | 46 +++++++++ .../runtime/activityownership/test_control.py | 97 +++++++++++++++++++ .../runtime/activityownership/test_detail.py | 81 ++++++++-------- .../activityownership/test_negotiation.py | 70 +++++++++++++ .../activityownership/test_observerless.py | 21 ++++ tests/runtime/workingreporter/test_refresh.py | 3 +- 10 files changed, 322 insertions(+), 41 deletions(-) create mode 100644 tests/runtime/activityownership/test_cap.py create mode 100644 tests/runtime/activityownership/test_control.py create mode 100644 tests/runtime/activityownership/test_negotiation.py create mode 100644 tests/runtime/activityownership/test_observerless.py diff --git a/tests/platform/activity/kit.py b/tests/platform/activity/kit.py index ffa6fe6cb..dea19e889 100644 --- a/tests/platform/activity/kit.py +++ b/tests/platform/activity/kit.py @@ -22,7 +22,10 @@ def lifecycle_records(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecor class ActivityPeer: - """The activity endpoint, advertising ``version`` on every response.""" + """The activity endpoint, advertising ``version`` on every response. + + ``post_latency`` maps a 1-based POST number to the seconds it takes to answer. + """ def __init__(self) -> None: self.version: str | None = SUPPORTED_VERSION @@ -30,11 +33,14 @@ def __init__(self) -> None: self.requests: list[httpx.Request] = [] self.responses: deque[httpx.Response] = deque() self.stall_post_number: int | None = None + self.post_latency: dict[int, float] = {} async def respond(self, request: httpx.Request) -> httpx.Response: body = json.loads(request.content) self.requests.append(request) self.reports.append(body) + if latency := self.post_latency.get(len(self.reports)): + await asyncio.sleep(latency) if len(self.reports) == self.stall_post_number: await asyncio.Event().wait() response = ( diff --git a/tests/platform/activity/test_deadlines.py b/tests/platform/activity/test_deadlines.py index 7f6ceafe4..62230b7fe 100644 --- a/tests/platform/activity/test_deadlines.py +++ b/tests/platform/activity/test_deadlines.py @@ -10,11 +10,14 @@ from tests.platform.activity.kit import DETAIL, ActivityPeer DEADLINE_SECONDS = 2 +# Under the deadline alone, but the deadline cannot cover it plus a full resend. +REJECTION_LATENCY_SECONDS = 1.5 @pytest.fixture def stalled_resend(activity_peer: ActivityPeer) -> ActivityPeer: - """The detail POST is rejected and its detail-free resend never answers.""" + """The detail POST is slowly rejected and its detail-free resend never answers.""" + activity_peer.post_latency[1] = REJECTION_LATENCY_SECONDS activity_peer.responses.append(httpx.Response(422)) activity_peer.stall_post_number = 2 return activity_peer diff --git a/tests/runtime/activityownership/conftest.py b/tests/runtime/activityownership/conftest.py index 4b2e227bd..2c259722e 100644 --- a/tests/runtime/activityownership/conftest.py +++ b/tests/runtime/activityownership/conftest.py @@ -10,6 +10,8 @@ from band.runtime.execution import ExecutionContext from band.runtime.runtime import AgentRuntime from band.runtime.sessionconfig import SessionConfig +from band.runtime.tools.agent import AgentTools +from band.testing import FakeAgentTools from tests.runtime.activityownership.kit import ActivityPlatform from tests.runtime.helpers import ( AGENT_ID, @@ -56,3 +58,12 @@ def create( for ctx in contexts: activity_platform.activity_release.set() await ctx.stop() + + +@pytest.fixture(params=["fake", "bare"]) +async def observerless_tools( + request: pytest.FixtureRequest, activity_platform: ActivityPlatform +) -> AsyncIterator[object]: + """Tools built outside an execution, as adapters' unit tests build them.""" + async with rest_client_over(activity_platform.respond) as rest: + yield FakeAgentTools() if request.param == "fake" else AgentTools(ROOM_ID, rest) diff --git a/tests/runtime/activityownership/kit.py b/tests/runtime/activityownership/kit.py index a8b24a20a..53063ab67 100644 --- a/tests/runtime/activityownership/kit.py +++ b/tests/runtime/activityownership/kit.py @@ -2,17 +2,34 @@ import asyncio import json +from collections import deque from collections.abc import Awaitable from typing import Any, Literal import httpx +from band.platform.activity import API_VERSION_HEADER +from tests.activitysupport import SUPPORTED_VERSION from tests.runtime.helpers import ALICE, LifecyclePlatform, page +REASONING_BODY = {"working": True, "activity": {"kind": "reasoning"}} +PERMISSION_BODY = {"working": True, "activity": {"kind": "permission"}} +MEMORY_BODY = {"working": True, "activity": {"kind": "memory"}} +BOOLEAN_BODY = {"working": True} +IDLE_BODY = {"working": False} + + +def tool_body(name: str) -> dict[str, Any]: + return {"working": True, "activity": {"kind": "tool_call", "name": name}} + class ActivityPlatform(LifecyclePlatform): + """Activity posts advertise a detail-supporting version; queued responses + answer them before the default echo.""" + def __init__(self) -> None: super().__init__() + self.activity_responses: deque[httpx.Response] = deque() self.activity_bodies: list[dict[str, Any]] = [] self.hold_next_activity = False self.activity_entered = asyncio.Event() @@ -22,6 +39,9 @@ def __init__(self) -> None: async def respond(self, request: httpx.Request) -> httpx.Response: response = self.answer(request) if request.url.path.endswith("/activity"): + if self.activity_responses: + response = self.activity_responses.popleft() + response.headers[API_VERSION_HEADER] = SUPPORTED_VERSION async with self._reported: self.activity_bodies.append(json.loads(request.content)) self._reported.notify_all() @@ -47,6 +67,7 @@ def answer(self, request: httpx.Request) -> httpx.Response: if tail == "participants": return page([{**ALICE, "role": "member", "status": "active"}]) if request.method == "POST" and tail == "messages": + self.posts.append(tail) return httpx.Response( 201, json={"data": {"id": "m-1", "success": True, "recipients": []}} ) diff --git a/tests/runtime/activityownership/test_cap.py b/tests/runtime/activityownership/test_cap.py new file mode 100644 index 000000000..dbc6735d2 --- /dev/null +++ b/tests/runtime/activityownership/test_cap.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +import pytest + +from band.platform.event import PlatformEvent +from band.runtime.execution import ExecutionContext +from band.runtime.tools.agent import AgentTools +from band.runtime.working_state import report_tool_activity, set_permission_wait +from tests.conftest import make_message_event +from tests.runtime.activityownership.kit import ( + IDLE_BODY, + REASONING_BODY, + ActivityPlatform, + tool_body, +) + +CAP_SECONDS = 10.0 +# Keep-alives at 3, 6 and 9 s; the one due at 12 s finds the cap passed. +LONG_TOOL_SECONDS = 15.0 + + +@pytest.mark.looptime +class TestDetailCap: + async def test_no_detail_is_reported_past_the_cap_until_the_turn_ends( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + await activity_platform.shown(report_tool_activity(tools, "Bash")) + await asyncio.sleep(LONG_TOOL_SECONDS) + await report_tool_activity(tools, "Edit") + await set_permission_wait(tools, True) + await set_permission_wait(tools, False) + + ctx = execution(handler, max_working_state_seconds=CAP_SECONDS) + await ctx._process_event(make_message_event()) + assert activity_platform.activity_bodies == [ + REASONING_BODY, + *[tool_body("Bash")] * 4, + IDLE_BODY, + ] diff --git a/tests/runtime/activityownership/test_control.py b/tests/runtime/activityownership/test_control.py new file mode 100644 index 000000000..773fbb5a6 --- /dev/null +++ b/tests/runtime/activityownership/test_control.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +import pytest + +from band.core.turn_lifecycle import TurnTask, bind_turn_task +from band.platform.event import PlatformEvent +from band.runtime.execution import ExecutionContext +from band.runtime.tools.agent import AgentTools +from band.runtime.working_state import report_memory_activity, report_tool_activity +from tests.conftest import make_message_event +from tests.runtime.activityownership.kit import ( + IDLE_BODY, + REASONING_BODY, + ActivityPlatform, + tool_body, +) + + +@pytest.mark.looptime +class TestControlledTurnDetail: + async def test_a_new_message_after_an_interrupt_shows_only_its_own_detail( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + cleanup_entered = asyncio.Event() + cleanup_release = asyncio.Event() + workers: list[TurnTask] = [] + + async def bash(tools: AgentTools) -> None: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cleanup_entered.set() + await cleanup_release.wait() + await report_memory_activity(tools) + raise + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + if workers: + cleanup_release.set() + await asyncio.gather(workers[0].task, return_exceptions=True) + await activity_platform.shown(report_tool_activity(tools, "Read")) + return + await activity_platform.shown(report_tool_activity(tools, "Bash")) + workers.append(bind_turn_task(tools, asyncio.create_task(bash(tools)))) + + ctx = execution(handler) + await ctx._process_event(make_message_event(msg_id="interrupted")) + assert ctx.interrupt() + await cleanup_entered.wait() + await ctx._process_event(make_message_event(msg_id="next")) + assert workers[0].task.cancelled() + assert activity_platform.activity_bodies == [ + REASONING_BODY, + tool_body("Bash"), + IDLE_BODY, + REASONING_BODY, + tool_body("Read"), + IDLE_BODY, + ] + + async def test_a_stop_echoed_on_a_detail_refresh_cancels_the_turn( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + captured: list[AgentTools] = [] + cancelled = asyncio.Event() + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + captured.append(tools) + await activity_platform.shown(report_tool_activity(tools, "Bash")) + activity_platform.stopped = True + await report_tool_activity(tools, "Read") + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + await report_memory_activity(tools) + cancelled.set() + raise + + ctx = execution(handler) + await ctx._process_event(make_message_event()) + assert cancelled.is_set() + await report_tool_activity(captured[0], "Edit") + assert activity_platform.activity_bodies == [ + REASONING_BODY, + tool_body("Bash"), + tool_body("Read"), + IDLE_BODY, + ] diff --git a/tests/runtime/activityownership/test_detail.py b/tests/runtime/activityownership/test_detail.py index 75aa2009b..ec2997811 100644 --- a/tests/runtime/activityownership/test_detail.py +++ b/tests/runtime/activityownership/test_detail.py @@ -16,12 +16,21 @@ set_permission_wait, ) from tests.conftest import make_message_event -from tests.runtime.activityownership.kit import ActivityPlatform, ObservedIdle +from tests.runtime.activityownership.kit import ( + IDLE_BODY, + MEMORY_BODY, + PERMISSION_BODY, + REASONING_BODY, + ActivityPlatform, + ObservedIdle, + tool_body, +) from tests.runtime.helpers import ROOM_ID class TestOwnedDetail: - async def test_real_tools_observer_reports_canonical_wire_detail( + @pytest.mark.looptime + async def test_a_turn_through_a_permission_wait_reports_each_visible_step( self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform, @@ -29,22 +38,25 @@ async def test_real_tools_observer_reports_canonical_wire_detail( async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: tools = AgentTools.from_context(ctx) await activity_platform.shown(report_tool_activity(tools, "Bash")) + await activity_platform.shown(end_tool_activity(tools)) await activity_platform.shown(set_permission_wait(tools, True)) # Hidden behind the wait: the room keeps seeing permission. - await end_tool_activity(tools) - await report_memory_activity(tools) + await report_tool_activity(tools, "Edit") await activity_platform.shown(set_permission_wait(tools, False)) - await activity_platform.shown(end_tool_activity(tools)) + await activity_platform.shown(report_memory_activity(tools)) + await tools.send_message("Done", mentions=["alice"]) ctx = execution(handler) await ctx._process_event(make_message_event()) + assert activity_platform.posts == ["messages"] assert activity_platform.activity_bodies == [ - {"working": True, "activity": {"kind": "reasoning"}}, - {"working": True, "activity": {"kind": "tool_call", "name": "Bash"}}, - {"working": True, "activity": {"kind": "permission"}}, - {"working": True, "activity": {"kind": "memory"}}, - {"working": True, "activity": {"kind": "reasoning"}}, - {"working": False}, + REASONING_BODY, + tool_body("Bash"), + REASONING_BODY, + PERMISSION_BODY, + tool_body("Edit"), + MEMORY_BODY, + IDLE_BODY, ] @pytest.mark.looptime @@ -70,21 +82,15 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await ctx._process_event(make_message_event(msg_id="original")) await ctx._process_event(make_message_event(msg_id="busy")) await asyncio.sleep(6.1) - assert ( - activity_platform.activity_bodies[-2:] - == [{"working": True, "activity": {"kind": "permission"}}] * 2 - ) + assert activity_platform.activity_bodies[-2:] == [PERMISSION_BODY] * 2 await report_tool_activity(captured[0], "Bash") await activity_platform.shown(set_permission_wait(captured[0], False)) - assert activity_platform.activity_bodies[-1] == { - "working": True, - "activity": {"kind": "tool_call", "name": "Bash"}, - } + assert activity_platform.activity_bodies[-1] == tool_body("Bash") release.set() await workers[0].task await asyncio.sleep(0) await ctx._join_activity_completion() - assert activity_platform.activity_bodies[-1] == {"working": False} + assert activity_platform.activity_bodies[-1] == IDLE_BODY before = list(activity_platform.activity_bodies) await end_tool_activity(captured[0]) assert activity_platform.activity_bodies == before @@ -104,9 +110,10 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await ctx._join_activity_completion() before = list(activity_platform.activity_bodies) await report_tool_activity(captured[0], "Bash") - assert before[-1] == {"working": False} + assert before[-1] == IDLE_BODY assert activity_platform.activity_bodies == before + @pytest.mark.looptime async def test_graceful_stop_keeps_the_draining_turn_detail_current( self, execution: Callable[..., ExecutionContext], @@ -132,9 +139,9 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: release.set() assert await stopping assert activity_platform.activity_bodies == [ - {"working": True, "activity": {"kind": "reasoning"}}, - {"working": True, "activity": {"kind": "tool_call", "name": "Bash"}}, - {"working": False}, + REASONING_BODY, + tool_body("Bash"), + IDLE_BODY, ] @pytest.mark.looptime @@ -167,9 +174,9 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: cleanup_release.set() await turn assert activity_platform.activity_bodies == [ - {"working": True, "activity": {"kind": "reasoning"}}, - {"working": True, "activity": {"kind": "tool_call", "name": "Bash"}}, - {"working": False}, + REASONING_BODY, + tool_body("Bash"), + IDLE_BODY, ] async def test_observation_from_a_waiting_delivery_never_starts_reporting( @@ -205,11 +212,12 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await ctx._join_activity_completion() before = list(activity_platform.activity_bodies) await report_tool_activity(waiting_tools[0], "Bash") - assert before[-1] == {"working": False} + assert before[-1] == IDLE_BODY assert activity_platform.activity_bodies == before waiting_release.set() await waiting + @pytest.mark.looptime async def test_a_new_turn_starts_at_reasoning( self, execution: Callable[..., ExecutionContext], @@ -229,12 +237,12 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await ctx._process_event(make_message_event(msg_id="first")) await ctx._process_event(make_message_event(msg_id="second")) assert activity_platform.activity_bodies == [ - {"working": True, "activity": {"kind": "reasoning"}}, - {"working": True, "activity": {"kind": "tool_call", "name": "Bash"}}, - {"working": True, "activity": {"kind": "permission"}}, - {"working": False}, - {"working": True, "activity": {"kind": "reasoning"}}, - {"working": False}, + REASONING_BODY, + tool_body("Bash"), + PERMISSION_BODY, + IDLE_BODY, + REASONING_BODY, + IDLE_BODY, ] async def test_a_tool_never_waits_for_the_platform( @@ -255,10 +263,7 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: ctx = execution(handler) await ctx._process_event(make_message_event()) assert observed_before_delivery == [True] - assert activity_platform.activity_bodies[1] == { - "working": True, - "activity": {"kind": "tool_call", "name": "Bash"}, - } + assert activity_platform.activity_bodies[1] == tool_body("Bash") @pytest.mark.parametrize("excluded", ["disabled", "hub"]) async def test_excluded_reporting_ignores_observations( diff --git a/tests/runtime/activityownership/test_negotiation.py b/tests/runtime/activityownership/test_negotiation.py new file mode 100644 index 000000000..00196e017 --- /dev/null +++ b/tests/runtime/activityownership/test_negotiation.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from collections.abc import Callable + +import httpx +import pytest + +from band.platform.event import PlatformEvent +from band.runtime.execution import ExecutionContext +from band.runtime.tools.agent import AgentTools +from band.runtime.working_state import report_tool_activity +from tests.activitysupport import install_activity_support +from tests.conftest import make_message_event +from tests.runtime.activityownership.kit import ( + BOOLEAN_BODY, + IDLE_BODY, + REASONING_BODY, + ActivityPlatform, + tool_body, +) + + +@pytest.mark.looptime +class TestTurnNegotiation: + async def test_a_fresh_process_learns_detail_from_its_first_report( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + install_activity_support(monkeypatch, learned=False) + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + await activity_platform.shown(report_tool_activity(tools, "Bash")) + + ctx = execution(handler) + await ctx._process_event(make_message_event()) + assert activity_platform.activity_bodies == [ + BOOLEAN_BODY, + tool_body("Bash"), + IDLE_BODY, + ] + + async def test_a_rejected_detail_mid_turn_leaves_every_later_report_boolean( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + if event.payload.id == "second": + return + tools = AgentTools.from_context(ctx) + activity_platform.activity_responses.append(httpx.Response(422)) + await activity_platform.shown(report_tool_activity(tools, "Bash")) + # The resend, then one keep-alive. + await activity_platform.bodies_reach(4) + + ctx = execution(handler) + await ctx._process_event(make_message_event(msg_id="first")) + await ctx._process_event(make_message_event(msg_id="second")) + assert activity_platform.activity_bodies == [ + REASONING_BODY, + tool_body("Bash"), + BOOLEAN_BODY, + BOOLEAN_BODY, + IDLE_BODY, + BOOLEAN_BODY, + IDLE_BODY, + ] diff --git a/tests/runtime/activityownership/test_observerless.py b/tests/runtime/activityownership/test_observerless.py new file mode 100644 index 000000000..baba90a0a --- /dev/null +++ b/tests/runtime/activityownership/test_observerless.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from band.runtime.working_state import ( + end_tool_activity, + report_memory_activity, + report_tool_activity, + set_permission_wait, +) +from tests.runtime.activityownership.kit import ActivityPlatform + + +class TestObserverlessProducers: + async def test_producers_are_silent_without_an_observer( + self, observerless_tools: object, activity_platform: ActivityPlatform + ) -> None: + await report_tool_activity(observerless_tools, "Bash") + await set_permission_wait(observerless_tools, True) + await report_memory_activity(observerless_tools) + await set_permission_wait(observerless_tools, False) + await end_tool_activity(observerless_tools) + assert activity_platform.activity_bodies == [] diff --git a/tests/runtime/workingreporter/test_refresh.py b/tests/runtime/workingreporter/test_refresh.py index 371f3228d..a26606d64 100644 --- a/tests/runtime/workingreporter/test_refresh.py +++ b/tests/runtime/workingreporter/test_refresh.py @@ -64,6 +64,7 @@ async def test_a_change_past_the_cap_is_not_reported(self) -> None: await asyncio.sleep(2.1) recorder.detail = "Bash" reporter.refresh() - await asyncio.sleep(1) + # The wake finds the cap passed and ends the keep-alive without a report. + await reporter._task await reporter.stop() assert recorder.reports == ["reasoning", None] From 5037fb939ae6dcaa97d3a766fbb428ca936ade15 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 15:16:08 +0300 Subject: [PATCH 23/33] refactor: Move the activity producers beside the observer they report to The producers map tool events to activity detail, which is not keep-alive reporting, so they live with ActivityObserver and tool_call_activity. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- src/band/runtime/activity.py | 27 +++++++++++++++- src/band/runtime/working_state.py | 31 ------------------- tests/runtime/activityownership/test_cap.py | 2 +- .../runtime/activityownership/test_control.py | 2 +- .../runtime/activityownership/test_detail.py | 6 ++-- .../activityownership/test_negotiation.py | 2 +- .../activityownership/test_observerless.py | 2 +- 7 files changed, 33 insertions(+), 39 deletions(-) diff --git a/src/band/runtime/activity.py b/src/band/runtime/activity.py index d37ecfae9..62de696b3 100644 --- a/src/band/runtime/activity.py +++ b/src/band/runtime/activity.py @@ -1,4 +1,5 @@ -"""Names-only room activity detail and the per-turn state that holds it.""" +"""Names-only room activity detail, the per-turn state that holds it, and the +producers a turn reports it through.""" from __future__ import annotations @@ -53,3 +54,27 @@ def apply( self.progress = names_only(activity) if waiting is not None: self.waiting = waiting + + +def _observer(tools: object) -> ActivityObserver | None: + return getattr(tools, "_activity_observer", None) + + +async def report_tool_activity(tools: object, name: str) -> None: + if observer := _observer(tools): + await observer.update(tool_call_activity(name)) + + +async def end_tool_activity(tools: object) -> None: + if observer := _observer(tools): + await observer.update(REASONING) + + +async def report_memory_activity(tools: object) -> None: + if observer := _observer(tools): + await observer.update(MEMORY) + + +async def set_permission_wait(tools: object, waiting: bool) -> None: + if observer := _observer(tools): + await observer.set_manual_wait(waiting) diff --git a/src/band/runtime/working_state.py b/src/band/runtime/working_state.py index 7ca25a809..540c46b9f 100644 --- a/src/band/runtime/working_state.py +++ b/src/band/runtime/working_state.py @@ -34,42 +34,11 @@ from collections.abc import Awaitable, Callable from contextlib import suppress -from band.runtime.activity import ( - MEMORY, - REASONING, - ActivityObserver, - tool_call_activity, -) - logger = logging.getLogger(__name__) ReportFn = Callable[[bool], Awaitable[object]] -def _observer(tools: object) -> ActivityObserver | None: - return getattr(tools, "_activity_observer", None) - - -async def report_tool_activity(tools: object, name: str) -> None: - if observer := _observer(tools): - await observer.update(tool_call_activity(name)) - - -async def end_tool_activity(tools: object) -> None: - if observer := _observer(tools): - await observer.update(REASONING) - - -async def report_memory_activity(tools: object) -> None: - if observer := _observer(tools): - await observer.update(MEMORY) - - -async def set_permission_wait(tools: object, waiting: bool) -> None: - if observer := _observer(tools): - await observer.set_manual_wait(waiting) - - def _retrieve_task_error(task: asyncio.Task[None]) -> None: if not task.cancelled(): task.exception() diff --git a/tests/runtime/activityownership/test_cap.py b/tests/runtime/activityownership/test_cap.py index dbc6735d2..f0334325a 100644 --- a/tests/runtime/activityownership/test_cap.py +++ b/tests/runtime/activityownership/test_cap.py @@ -6,9 +6,9 @@ import pytest from band.platform.event import PlatformEvent +from band.runtime.activity import report_tool_activity, set_permission_wait from band.runtime.execution import ExecutionContext from band.runtime.tools.agent import AgentTools -from band.runtime.working_state import report_tool_activity, set_permission_wait from tests.conftest import make_message_event from tests.runtime.activityownership.kit import ( IDLE_BODY, diff --git a/tests/runtime/activityownership/test_control.py b/tests/runtime/activityownership/test_control.py index 773fbb5a6..6e5db7e96 100644 --- a/tests/runtime/activityownership/test_control.py +++ b/tests/runtime/activityownership/test_control.py @@ -7,9 +7,9 @@ from band.core.turn_lifecycle import TurnTask, bind_turn_task from band.platform.event import PlatformEvent +from band.runtime.activity import report_memory_activity, report_tool_activity from band.runtime.execution import ExecutionContext from band.runtime.tools.agent import AgentTools -from band.runtime.working_state import report_memory_activity, report_tool_activity from tests.conftest import make_message_event from tests.runtime.activityownership.kit import ( IDLE_BODY, diff --git a/tests/runtime/activityownership/test_detail.py b/tests/runtime/activityownership/test_detail.py index ec2997811..94978f51a 100644 --- a/tests/runtime/activityownership/test_detail.py +++ b/tests/runtime/activityownership/test_detail.py @@ -7,14 +7,14 @@ from band.core.turn_lifecycle import TurnTask, bind_turn_task from band.platform.event import PlatformEvent -from band.runtime.execution import ExecutionContext -from band.runtime.tools.agent import AgentTools -from band.runtime.working_state import ( +from band.runtime.activity import ( end_tool_activity, report_memory_activity, report_tool_activity, set_permission_wait, ) +from band.runtime.execution import ExecutionContext +from band.runtime.tools.agent import AgentTools from tests.conftest import make_message_event from tests.runtime.activityownership.kit import ( IDLE_BODY, diff --git a/tests/runtime/activityownership/test_negotiation.py b/tests/runtime/activityownership/test_negotiation.py index 00196e017..0f8d2b816 100644 --- a/tests/runtime/activityownership/test_negotiation.py +++ b/tests/runtime/activityownership/test_negotiation.py @@ -6,9 +6,9 @@ import pytest from band.platform.event import PlatformEvent +from band.runtime.activity import report_tool_activity from band.runtime.execution import ExecutionContext from band.runtime.tools.agent import AgentTools -from band.runtime.working_state import report_tool_activity from tests.activitysupport import install_activity_support from tests.conftest import make_message_event from tests.runtime.activityownership.kit import ( diff --git a/tests/runtime/activityownership/test_observerless.py b/tests/runtime/activityownership/test_observerless.py index baba90a0a..05c76c535 100644 --- a/tests/runtime/activityownership/test_observerless.py +++ b/tests/runtime/activityownership/test_observerless.py @@ -1,6 +1,6 @@ from __future__ import annotations -from band.runtime.working_state import ( +from band.runtime.activity import ( end_tool_activity, report_memory_activity, report_tool_activity, From ec7f54cb31885ca39c18fe5856d537db5c53568d Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 15:30:04 +0300 Subject: [PATCH 24/33] test: Share one set of activity report bodies across the activity suites The platform and turn-ownership suites retyped the same request bodies; tests/activitysupport.py now owns them, built from the runtime's activity kinds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- tests/activitysupport.py | 24 +++++++++++++++++++- tests/platform/activity/conftest.py | 4 ++-- tests/platform/activity/kit.py | 2 -- tests/platform/activity/test_control_link.py | 5 ++-- tests/platform/activity/test_deadlines.py | 9 ++++---- tests/platform/activity/test_fallback.py | 13 ++++++----- tests/platform/activity/test_versions.py | 22 ++++++++++-------- tests/runtime/activity/test_turndetail.py | 6 +++-- 8 files changed, 57 insertions(+), 28 deletions(-) diff --git a/tests/activitysupport.py b/tests/activitysupport.py index eeba99419..a046463dd 100644 --- a/tests/activitysupport.py +++ b/tests/activitysupport.py @@ -1,7 +1,9 @@ -"""Isolate the process-wide activity-detail negotiation per test.""" +"""Activity report bodies and per-test isolation of the detail negotiation.""" from __future__ import annotations +from typing import Any + import pytest import band.platform.message_lifecycle as lifecycle @@ -10,9 +12,29 @@ MINIMUM_ACTIVITY_VERSION, ActivitySupport, ) +from band.runtime.activity import MEMORY, PERMISSION, REASONING, TOOL_CALL SUPPORTED_VERSION = ".".join(map(str, MINIMUM_ACTIVITY_VERSION)) +BOOLEAN_BODY = {"working": True} +IDLE_BODY = {"working": False} + + +def detail_body(kind: str, name: str | None = None) -> dict[str, Any]: + activity = {"kind": kind} if name is None else {"kind": kind, "name": name} + return {**BOOLEAN_BODY, "activity": activity} + + +def tool_body(name: str) -> dict[str, Any]: + return detail_body(TOOL_CALL, name) + + +REASONING_BODY = detail_body(REASONING.kind) +PERMISSION_BODY = detail_body(PERMISSION.kind) +MEMORY_BODY = detail_body(MEMORY.kind) +DETAIL_BODY = tool_body("Bash") +DETAIL = DETAIL_BODY["activity"] + def install_activity_support( monkeypatch: pytest.MonkeyPatch, *, learned: bool diff --git a/tests/platform/activity/conftest.py b/tests/platform/activity/conftest.py index f14f18e0e..efe8f3861 100644 --- a/tests/platform/activity/conftest.py +++ b/tests/platform/activity/conftest.py @@ -7,8 +7,8 @@ from band.platform.activity import ActivitySupport from band.platform.link import BandLink -from tests.activitysupport import install_activity_support -from tests.platform.activity.kit import DETAIL, ActivityPeer +from tests.activitysupport import DETAIL, install_activity_support +from tests.platform.activity.kit import ActivityPeer from tests.runtime.helpers import rest_client_over diff --git a/tests/platform/activity/kit.py b/tests/platform/activity/kit.py index dea19e889..51b1a14cc 100644 --- a/tests/platform/activity/kit.py +++ b/tests/platform/activity/kit.py @@ -13,8 +13,6 @@ from band.platform.message_lifecycle import logger as lifecycle_logger from tests.activitysupport import SUPPORTED_VERSION -DETAIL = {"kind": "tool_call", "name": "Bash"} - def lifecycle_records(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecord]: """Records of the delivery logger alone, not the version negotiation's.""" diff --git a/tests/platform/activity/test_control_link.py b/tests/platform/activity/test_control_link.py index 008781d86..00e8d78c6 100644 --- a/tests/platform/activity/test_control_link.py +++ b/tests/platform/activity/test_control_link.py @@ -4,8 +4,9 @@ from band_rest import AgentActivityDetail from band.platform.link import BandLink +from tests.activitysupport import DETAIL_BODY from tests.e2e.baseline.toolkit.control import ControlLink, ControlRuntime -from tests.platform.activity.kit import DETAIL, ActivityPeer +from tests.platform.activity.kit import ActivityPeer @pytest.mark.usefixtures("learned") @@ -18,4 +19,4 @@ async def test_baseline_control_link_forwards_activity_detail( control_link.control = ControlRuntime() control_link.control_room_id = "room" assert await control_link.report_activity("room", True, activity=detail) - assert activity_peer.reports == [{"working": True, "activity": DETAIL}] + assert activity_peer.reports == [DETAIL_BODY] diff --git a/tests/platform/activity/test_deadlines.py b/tests/platform/activity/test_deadlines.py index 62230b7fe..5c36e6f3c 100644 --- a/tests/platform/activity/test_deadlines.py +++ b/tests/platform/activity/test_deadlines.py @@ -7,7 +7,8 @@ from band_rest import AgentActivityDetail from band.platform.link import BandLink -from tests.platform.activity.kit import DETAIL, ActivityPeer +from tests.activitysupport import BOOLEAN_BODY, DETAIL_BODY +from tests.platform.activity.kit import ActivityPeer DEADLINE_SECONDS = 2 # Under the deadline alone, but the deadline cannot cover it plus a full resend. @@ -35,8 +36,8 @@ async def test_resend_cannot_extend_the_report_deadline( ) assert asyncio.get_running_loop().time() - started == DEADLINE_SECONDS assert stalled_resend.reports == [ - {"working": True, "activity": DETAIL}, - {"working": True}, + DETAIL_BODY, + BOOLEAN_BODY, ] async def test_downgrade_survives_a_timed_out_resend( @@ -47,4 +48,4 @@ async def test_downgrade_survives_a_timed_out_resend( ) stalled_resend.stall_post_number = None assert await link.report_activity("next", True, activity=detail) - assert stalled_resend.reports[-1] == {"working": True} + assert stalled_resend.reports[-1] == BOOLEAN_BODY diff --git a/tests/platform/activity/test_fallback.py b/tests/platform/activity/test_fallback.py index d07101e6a..dfb20b8d6 100644 --- a/tests/platform/activity/test_fallback.py +++ b/tests/platform/activity/test_fallback.py @@ -8,7 +8,8 @@ from band.platform.activity import ActivitySupport from band.platform.link import BandLink -from tests.platform.activity.kit import DETAIL, ActivityPeer +from tests.activitysupport import BOOLEAN_BODY, DETAIL_BODY +from tests.platform.activity.kit import ActivityPeer @pytest.mark.usefixtures("learned") @@ -28,9 +29,9 @@ async def test_422_retries_without_detail_and_latches_across_links( assert await link.report_activity("first", True, activity=detail) assert await second_link.report_activity("second", True, activity=detail) assert activity_peer.reports == [ - {"working": True, "activity": DETAIL}, - {"working": True}, - {"working": True}, + DETAIL_BODY, + BOOLEAN_BODY, + BOOLEAN_BODY, ] @pytest.mark.parametrize( @@ -66,7 +67,7 @@ async def test_other_errors_do_not_retry_or_disable( ) assert not await link.report_activity("room", True, activity=detail) assert await link.report_activity("room", True, activity=detail) - assert activity_peer.reports == [{"working": True, "activity": DETAIL}] * 2 + assert activity_peer.reports == [DETAIL_BODY] * 2 async def test_fallback_echoed_false_keeps_stop_notification( self, link: BandLink, activity_peer: ActivityPeer, detail: AgentActivityDetail @@ -82,4 +83,4 @@ async def test_fallback_echoed_false_keeps_stop_notification( "room", True, activity=detail, on_stopped=stopped ) stopped.assert_called_once_with() - assert activity_peer.reports[-1] == {"working": True} + assert activity_peer.reports[-1] == BOOLEAN_BODY diff --git a/tests/platform/activity/test_versions.py b/tests/platform/activity/test_versions.py index bb7a4bab6..224e8bf12 100644 --- a/tests/platform/activity/test_versions.py +++ b/tests/platform/activity/test_versions.py @@ -4,8 +4,14 @@ from band_rest import AgentActivityDetail from band.platform.link import BandLink -from tests.activitysupport import SUPPORTED_VERSION -from tests.platform.activity.kit import DETAIL, ActivityPeer +from tests.activitysupport import ( + BOOLEAN_BODY, + DETAIL_BODY, + IDLE_BODY, + SUPPORTED_VERSION, + detail_body, +) +from tests.platform.activity.kit import ActivityPeer class TestActivityVersion: @@ -34,10 +40,8 @@ async def test_advertised_version_controls_later_detail( activity_peer.version = version assert await link.report_activity("room", True, activity=detail) assert await link.report_activity("room", True, activity=detail) - later = ( - {"working": True, "activity": DETAIL} if supported else {"working": True} - ) - assert activity_peer.reports == [{"working": True}, later] + later = DETAIL_BODY if supported else BOOLEAN_BODY + assert activity_peer.reports == [BOOLEAN_BODY, later] assert all( request.url.path.endswith("/activity") for request in activity_peer.requests ) @@ -51,7 +55,7 @@ async def test_learned_support_is_shared_across_links( ) -> None: assert await link.report_activity("first", True) assert await second_link.report_activity("second", True, activity=detail) - assert activity_peer.reports[-1] == {"working": True, "activity": DETAIL} + assert activity_peer.reports[-1] == DETAIL_BODY @pytest.mark.parametrize("kind", ["reasoning", "memory", "permission"]) async def test_non_tool_detail_omits_name_and_false_omits_detail( @@ -62,6 +66,6 @@ async def test_non_tool_detail_omits_name_and_false_omits_detail( assert await link.report_activity("room", True, activity=detail) assert await link.report_activity("room", False, activity=detail) assert activity_peer.reports[1:] == [ - {"working": True, "activity": {"kind": kind}}, - {"working": False}, + detail_body(kind), + IDLE_BODY, ] diff --git a/tests/runtime/activity/test_turndetail.py b/tests/runtime/activity/test_turndetail.py index fc2c39a19..bb87d8f39 100644 --- a/tests/runtime/activity/test_turndetail.py +++ b/tests/runtime/activity/test_turndetail.py @@ -2,7 +2,7 @@ from band_rest import AgentActivityDetail -from band.runtime.activity import TurnActivity +from band.runtime.activity import UNKNOWN_ACTIVITY_TOOL_NAME, TurnActivity class TestTurnActivity: @@ -26,7 +26,9 @@ def test_only_the_kind_and_tool_name_are_kept(self) -> None: def test_a_nameless_tool_is_reported_as_unknown(self) -> None: activity = TurnActivity() activity.apply(activity=AgentActivityDetail(kind="tool_call", name="")) - assert activity.detail == AgentActivityDetail(kind="tool_call", name="unknown") + assert activity.detail == AgentActivityDetail( + kind="tool_call", name=UNKNOWN_ACTIVITY_TOOL_NAME + ) def test_a_person_wait_overrides_progress_without_losing_it(self) -> None: activity = TurnActivity() From 1b98ee91b4b47a3be4addb6c84060c2aa24f5606 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 15:30:04 +0300 Subject: [PATCH 25/33] test: Prove visible activity changes report without waiting for a keep-alive shown() records any body that needed virtual time, which only a keep-alive could supply, and the platform fixture fails the test on it. The settled-owner test splits so a busy delivery reports while the owner shows a tool, waits on reported bodies instead of fixed sleeps, and post-turn observations are checked after a keep-alive could have fired. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- tests/runtime/activityownership/conftest.py | 19 ++-- tests/runtime/activityownership/kit.py | 38 ++++++-- tests/runtime/activityownership/test_cap.py | 8 +- .../runtime/activityownership/test_control.py | 9 +- .../runtime/activityownership/test_detail.py | 86 +++++++++++++++---- .../activityownership/test_negotiation.py | 8 +- .../activityownership/test_observerless.py | 23 ++--- 7 files changed, 124 insertions(+), 67 deletions(-) diff --git a/tests/runtime/activityownership/conftest.py b/tests/runtime/activityownership/conftest.py index 2c259722e..2b012bccc 100644 --- a/tests/runtime/activityownership/conftest.py +++ b/tests/runtime/activityownership/conftest.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator from unittest.mock import AsyncMock import pytest @@ -11,7 +11,6 @@ from band.runtime.runtime import AgentRuntime from band.runtime.sessionconfig import SessionConfig from band.runtime.tools.agent import AgentTools -from band.testing import FakeAgentTools from tests.runtime.activityownership.kit import ActivityPlatform from tests.runtime.helpers import ( AGENT_ID, @@ -21,8 +20,10 @@ @pytest.fixture -def activity_platform() -> ActivityPlatform: - return ActivityPlatform() +def activity_platform() -> Iterator[ActivityPlatform]: + platform = ActivityPlatform() + yield platform + assert platform.late_changes == [], "a visible change waited for a keep-alive" @pytest.fixture @@ -60,10 +61,8 @@ def create( await ctx.stop() -@pytest.fixture(params=["fake", "bare"]) -async def observerless_tools( - request: pytest.FixtureRequest, activity_platform: ActivityPlatform -) -> AsyncIterator[object]: - """Tools built outside an execution, as adapters' unit tests build them.""" +@pytest.fixture +async def bare_tools(activity_platform: ActivityPlatform) -> AsyncIterator[AgentTools]: + """Real tools built outside an execution, as adapters' unit tests build them.""" async with rest_client_over(activity_platform.respond) as rest: - yield FakeAgentTools() if request.param == "fake" else AgentTools(ROOM_ID, rest) + yield AgentTools(ROOM_ID, rest) diff --git a/tests/runtime/activityownership/kit.py b/tests/runtime/activityownership/kit.py index 53063ab67..5af2db7f6 100644 --- a/tests/runtime/activityownership/kit.py +++ b/tests/runtime/activityownership/kit.py @@ -9,18 +9,30 @@ import httpx from band.platform.activity import API_VERSION_HEADER +from band.runtime.activity import ( + end_tool_activity, + report_memory_activity, + report_tool_activity, + set_permission_wait, +) +from band.runtime.sessionconfig import SessionConfig from tests.activitysupport import SUPPORTED_VERSION from tests.runtime.helpers import ALICE, LifecyclePlatform, page -REASONING_BODY = {"working": True, "activity": {"kind": "reasoning"}} -PERMISSION_BODY = {"working": True, "activity": {"kind": "permission"}} -MEMORY_BODY = {"working": True, "activity": {"kind": "memory"}} -BOOLEAN_BODY = {"working": True} -IDLE_BODY = {"working": False} +KEEP_ALIVE_SECONDS = SessionConfig().working_keep_alive_seconds -def tool_body(name: str) -> dict[str, Any]: - return {"working": True, "activity": {"kind": "tool_call", "name": name}} +async def past_a_keep_alive() -> None: + """Let a keep-alive fall due, so a wrongly woken reporter would have sent.""" + await asyncio.sleep(2 * KEEP_ALIVE_SECONDS) + + +async def report_every_kind(tools: object) -> None: + await report_tool_activity(tools, "Bash") + await set_permission_wait(tools, True) + await report_memory_activity(tools) + await set_permission_wait(tools, False) + await end_tool_activity(tools) class ActivityPlatform(LifecyclePlatform): @@ -34,6 +46,7 @@ def __init__(self) -> None: self.hold_next_activity = False self.activity_entered = asyncio.Event() self.activity_release = asyncio.Event() + self.late_changes: list[float] = [] self._reported = asyncio.Condition() async def respond(self, request: httpx.Request) -> httpx.Response: @@ -57,10 +70,19 @@ async def bodies_reach(self, count: int) -> list[dict[str, Any]]: return self.activity_bodies async def shown(self, observation: Awaitable[None]) -> None: - """Make a visible change and wait until the platform has received it.""" + """Make a visible change and wait until the platform has received it. + + A body that arrives only after virtual time passed came from a + keep-alive, not the change; it is recorded in ``late_changes`` rather + than raised, because a handler would swallow the error as a failed turn. + """ + loop = asyncio.get_running_loop() count = len(self.activity_bodies) + observed_at = loop.time() await observation await self.bodies_reach(count + 1) + if waited := loop.time() - observed_at: + self.late_changes.append(waited) def answer(self, request: httpx.Request) -> httpx.Response: tail = request.url.path.rsplit("/", 1)[-1] diff --git a/tests/runtime/activityownership/test_cap.py b/tests/runtime/activityownership/test_cap.py index f0334325a..70e8b5c26 100644 --- a/tests/runtime/activityownership/test_cap.py +++ b/tests/runtime/activityownership/test_cap.py @@ -9,13 +9,9 @@ from band.runtime.activity import report_tool_activity, set_permission_wait from band.runtime.execution import ExecutionContext from band.runtime.tools.agent import AgentTools +from tests.activitysupport import IDLE_BODY, REASONING_BODY, tool_body from tests.conftest import make_message_event -from tests.runtime.activityownership.kit import ( - IDLE_BODY, - REASONING_BODY, - ActivityPlatform, - tool_body, -) +from tests.runtime.activityownership.kit import ActivityPlatform CAP_SECONDS = 10.0 # Keep-alives at 3, 6 and 9 s; the one due at 12 s finds the cap passed. diff --git a/tests/runtime/activityownership/test_control.py b/tests/runtime/activityownership/test_control.py index 6e5db7e96..8aca8c272 100644 --- a/tests/runtime/activityownership/test_control.py +++ b/tests/runtime/activityownership/test_control.py @@ -10,13 +10,9 @@ from band.runtime.activity import report_memory_activity, report_tool_activity from band.runtime.execution import ExecutionContext from band.runtime.tools.agent import AgentTools +from tests.activitysupport import IDLE_BODY, REASONING_BODY, tool_body from tests.conftest import make_message_event -from tests.runtime.activityownership.kit import ( - IDLE_BODY, - REASONING_BODY, - ActivityPlatform, - tool_body, -) +from tests.runtime.activityownership.kit import ActivityPlatform, past_a_keep_alive @pytest.mark.looptime @@ -89,6 +85,7 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await ctx._process_event(make_message_event()) assert cancelled.is_set() await report_tool_activity(captured[0], "Edit") + await past_a_keep_alive() assert activity_platform.activity_bodies == [ REASONING_BODY, tool_body("Bash"), diff --git a/tests/runtime/activityownership/test_detail.py b/tests/runtime/activityownership/test_detail.py index 94978f51a..87ffab68d 100644 --- a/tests/runtime/activityownership/test_detail.py +++ b/tests/runtime/activityownership/test_detail.py @@ -15,15 +15,18 @@ ) from band.runtime.execution import ExecutionContext from band.runtime.tools.agent import AgentTools -from tests.conftest import make_message_event -from tests.runtime.activityownership.kit import ( +from tests.activitysupport import ( IDLE_BODY, MEMORY_BODY, PERMISSION_BODY, REASONING_BODY, + tool_body, +) +from tests.conftest import make_message_event +from tests.runtime.activityownership.kit import ( ActivityPlatform, ObservedIdle, - tool_body, + past_a_keep_alive, ) from tests.runtime.helpers import ROOM_ID @@ -60,7 +63,7 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: ] @pytest.mark.looptime - async def test_settled_owner_remains_authoritative_and_transient_tools_are_quiet( + async def test_a_settled_owner_keeps_reporting_its_worker_detail( self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform, @@ -71,30 +74,66 @@ async def test_settled_owner_remains_authoritative_and_transient_tools_are_quiet async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: tools = AgentTools.from_context(ctx) - if captured: + captured.append(tools) + await activity_platform.shown(report_tool_activity(tools, "Bash")) + workers.append(bind_turn_task(tools, asyncio.create_task(release.wait()))) + + ctx = execution(handler) + await ctx._process_event(make_message_event()) + owner = captured[0] + await activity_platform.shown(set_permission_wait(owner, True)) + await activity_platform.bodies_reach(len(activity_platform.activity_bodies) + 1) + # Hidden behind the wait: the room keeps seeing permission. + await report_tool_activity(owner, "Edit") + await activity_platform.shown(set_permission_wait(owner, False)) + release.set() + await workers[0].task + await activity_platform.bodies_reach(len(activity_platform.activity_bodies) + 1) + await ctx._join_activity_completion() + await end_tool_activity(owner) + await past_a_keep_alive() + assert activity_platform.activity_bodies == [ + REASONING_BODY, + tool_body("Bash"), + PERMISSION_BODY, + PERMISSION_BODY, + tool_body("Edit"), + IDLE_BODY, + ] + + @pytest.mark.looptime + async def test_a_busy_delivery_never_changes_the_owner_detail( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + release = asyncio.Event() + workers: list[TurnTask] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + if workers: await report_tool_activity(tools, "unrelated") + await set_permission_wait(tools, True) return - captured.append(tools) + await activity_platform.shown(report_tool_activity(tools, "Bash")) workers.append(bind_turn_task(tools, asyncio.create_task(release.wait()))) - await set_permission_wait(tools, True) ctx = execution(handler) - await ctx._process_event(make_message_event(msg_id="original")) + await ctx._process_event(make_message_event(msg_id="owner")) await ctx._process_event(make_message_event(msg_id="busy")) - await asyncio.sleep(6.1) - assert activity_platform.activity_bodies[-2:] == [PERMISSION_BODY] * 2 - await report_tool_activity(captured[0], "Bash") - await activity_platform.shown(set_permission_wait(captured[0], False)) - assert activity_platform.activity_bodies[-1] == tool_body("Bash") + await activity_platform.bodies_reach(len(activity_platform.activity_bodies) + 1) release.set() await workers[0].task - await asyncio.sleep(0) - await ctx._join_activity_completion() - assert activity_platform.activity_bodies[-1] == IDLE_BODY - before = list(activity_platform.activity_bodies) - await end_tool_activity(captured[0]) - assert activity_platform.activity_bodies == before + await activity_platform.bodies_reach(len(activity_platform.activity_bodies) + 1) + assert activity_platform.activity_bodies == [ + REASONING_BODY, + tool_body("Bash"), + tool_body("Bash"), + IDLE_BODY, + ] + @pytest.mark.looptime async def test_settled_turn_cannot_restart_reporting( self, execution: Callable[..., ExecutionContext], @@ -110,6 +149,7 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await ctx._join_activity_completion() before = list(activity_platform.activity_bodies) await report_tool_activity(captured[0], "Bash") + await past_a_keep_alive() assert before[-1] == IDLE_BODY assert activity_platform.activity_bodies == before @@ -179,6 +219,7 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: IDLE_BODY, ] + @pytest.mark.looptime async def test_observation_from_a_waiting_delivery_never_starts_reporting( self, execution: Callable[..., ExecutionContext], @@ -212,6 +253,7 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await ctx._join_activity_completion() before = list(activity_platform.activity_bodies) await report_tool_activity(waiting_tools[0], "Bash") + await past_a_keep_alive() assert before[-1] == IDLE_BODY assert activity_platform.activity_bodies == before waiting_release.set() @@ -245,24 +287,30 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: IDLE_BODY, ] + @pytest.mark.looptime async def test_a_tool_never_waits_for_the_platform( self, execution: Callable[..., ExecutionContext], activity_platform: ActivityPlatform, ) -> None: observed_before_delivery: list[bool] = [] + seconds_until_sent: list[float] = [] async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: tools = AgentTools.from_context(ctx) + loop = asyncio.get_running_loop() activity_platform.hold_next_activity = True + observed_at = loop.time() observing = asyncio.create_task(report_tool_activity(tools, "Bash")) await activity_platform.activity_entered.wait() + seconds_until_sent.append(loop.time() - observed_at) observed_before_delivery.append(observing.done()) activity_platform.activity_release.set() ctx = execution(handler) await ctx._process_event(make_message_event()) assert observed_before_delivery == [True] + assert seconds_until_sent == [0] assert activity_platform.activity_bodies[1] == tool_body("Bash") @pytest.mark.parametrize("excluded", ["disabled", "hub"]) diff --git a/tests/runtime/activityownership/test_negotiation.py b/tests/runtime/activityownership/test_negotiation.py index 0f8d2b816..8194956a5 100644 --- a/tests/runtime/activityownership/test_negotiation.py +++ b/tests/runtime/activityownership/test_negotiation.py @@ -9,15 +9,15 @@ from band.runtime.activity import report_tool_activity from band.runtime.execution import ExecutionContext from band.runtime.tools.agent import AgentTools -from tests.activitysupport import install_activity_support -from tests.conftest import make_message_event -from tests.runtime.activityownership.kit import ( +from tests.activitysupport import ( BOOLEAN_BODY, IDLE_BODY, REASONING_BODY, - ActivityPlatform, + install_activity_support, tool_body, ) +from tests.conftest import make_message_event +from tests.runtime.activityownership.kit import ActivityPlatform @pytest.mark.looptime diff --git a/tests/runtime/activityownership/test_observerless.py b/tests/runtime/activityownership/test_observerless.py index 05c76c535..ab8e9ee59 100644 --- a/tests/runtime/activityownership/test_observerless.py +++ b/tests/runtime/activityownership/test_observerless.py @@ -1,21 +1,16 @@ from __future__ import annotations -from band.runtime.activity import ( - end_tool_activity, - report_memory_activity, - report_tool_activity, - set_permission_wait, -) -from tests.runtime.activityownership.kit import ActivityPlatform +from band.runtime.tools.agent import AgentTools +from band.testing import FakeAgentTools +from tests.runtime.activityownership.kit import ActivityPlatform, report_every_kind class TestObserverlessProducers: - async def test_producers_are_silent_without_an_observer( - self, observerless_tools: object, activity_platform: ActivityPlatform + async def test_producers_accept_tools_that_carry_no_observer(self) -> None: + await report_every_kind(FakeAgentTools()) + + async def test_tools_outside_an_execution_report_nothing( + self, bare_tools: AgentTools, activity_platform: ActivityPlatform ) -> None: - await report_tool_activity(observerless_tools, "Bash") - await set_permission_wait(observerless_tools, True) - await report_memory_activity(observerless_tools) - await set_permission_wait(observerless_tools, False) - await end_tool_activity(observerless_tools) + await report_every_kind(bare_tools) assert activity_platform.activity_bodies == [] From 904dbe8d6c8850208fa10f7fddc11135c0125e6c Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 15:30:04 +0300 Subject: [PATCH 26/33] docs: State that an aborted owner's reports carry no detail The platform reads a report without activity as reasoning, so the room shows reasoning, not nothing, while an aborted owner unwinds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- docs/turn-outcome.md | 11 ++++++----- src/band/runtime/execution.py | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/turn-outcome.md b/docs/turn-outcome.md index e19b4d689..87c0e7a50 100644 --- a/docs/turn-outcome.md +++ b/docs/turn-outcome.md @@ -427,11 +427,12 @@ The authoritative idle report finishes before a replacement reports working. `SessionConfig.enable_working_state` controls both the indicator and activity detail, independently of chat narration and tool capabilities. Each turn keeps -its own detail, starting at reasoning; the room shows the owner's detail, and -none while an aborted owner unwinds. Reporting what a turn is doing never starts -reporting, changes ownership, or waits on the network: a visible change wakes -the reporter's keep-alive, which sends the latest detail, so changes made while -a report is in flight coalesce. The duration cap stops both kinds of refresh +its own detail, starting at reasoning; the room shows the owner's detail. An +aborted owner's stale detail is never sent: its reports while it unwinds carry +no detail, which the platform shows as reasoning. Reporting what a turn is doing +never starts reporting, changes ownership, or waits on the network: a visible +change wakes the reporter's keep-alive, which sends the latest detail, so +changes made while a report is in flight coalesce. The duration cap stops both kinds of refresh without cancelling provider work; actual completion still clears. Detail compatibility is learned from the `X-API-Version` header the platform diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index d64d3827b..760584e42 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -1948,7 +1948,8 @@ def activity_observer(self, scope: TurnScope | None) -> ActivityObserver | None: @property def _visible_activity(self) -> AgentActivityDetail | None: - """The owner's detail; an aborted owner's stale work is never shown.""" + """The owner's detail, or none for an aborted owner so its stale work is + never sent (the platform shows a report without detail as reasoning).""" owner = self._activity_owner if owner is None or owner.aborted: return None From 9f7d2ea8f3fa1765946ab4ac59150ef183ee1733 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 15:45:39 +0300 Subject: [PATCH 27/33] test: Split the activity detail suite by concern and flush before negative checks Each activity concern (owned detail, non-owner observations, controlled turns, exclusion, non-blocking delivery) gets its own class, every "nothing posted" check flushes past a keep-alive under looptime, and a late visible change now names the body that waited. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- tests/runtime/activityownership/conftest.py | 4 +- tests/runtime/activityownership/kit.py | 21 +- .../runtime/activityownership/test_control.py | 69 +++++- .../activityownership/test_delivery.py | 42 ++++ .../runtime/activityownership/test_detail.py | 223 +----------------- .../activityownership/test_exclusion.py | 44 ++++ .../activityownership/test_nonowner.py | 107 +++++++++ .../activityownership/test_observerless.py | 16 +- 8 files changed, 296 insertions(+), 230 deletions(-) create mode 100644 tests/runtime/activityownership/test_delivery.py create mode 100644 tests/runtime/activityownership/test_exclusion.py create mode 100644 tests/runtime/activityownership/test_nonowner.py diff --git a/tests/runtime/activityownership/conftest.py b/tests/runtime/activityownership/conftest.py index 2b012bccc..400cbe461 100644 --- a/tests/runtime/activityownership/conftest.py +++ b/tests/runtime/activityownership/conftest.py @@ -23,7 +23,9 @@ def activity_platform() -> Iterator[ActivityPlatform]: platform = ActivityPlatform() yield platform - assert platform.late_changes == [], "a visible change waited for a keep-alive" + assert platform.late_changes == [], ( + f"visible changes waited for a keep-alive (body, seconds): {platform.late_changes}" + ) @pytest.fixture diff --git a/tests/runtime/activityownership/kit.py b/tests/runtime/activityownership/kit.py index 5af2db7f6..c06a41bda 100644 --- a/tests/runtime/activityownership/kit.py +++ b/tests/runtime/activityownership/kit.py @@ -46,7 +46,7 @@ def __init__(self) -> None: self.hold_next_activity = False self.activity_entered = asyncio.Event() self.activity_release = asyncio.Event() - self.late_changes: list[float] = [] + self.late_changes: list[tuple[dict[str, Any], float]] = [] self._reported = asyncio.Condition() async def respond(self, request: httpx.Request) -> httpx.Response: @@ -69,20 +69,29 @@ async def bodies_reach(self, count: int) -> list[dict[str, Any]]: await self._reported.wait_for(lambda: len(self.activity_bodies) >= count) return self.activity_bodies + async def body_at(self, index: int) -> dict[str, Any]: + await self.bodies_reach(index + 1) + return self.activity_bodies[index] + + async def next_body(self) -> dict[str, Any]: + """Wait for the platform to receive one more activity body.""" + return await self.body_at(len(self.activity_bodies)) + async def shown(self, observation: Awaitable[None]) -> None: """Make a visible change and wait until the platform has received it. - A body that arrives only after virtual time passed came from a - keep-alive, not the change; it is recorded in ``late_changes`` rather + Assumes looptime: under real time every change would look late. A body + that arrives only after virtual time passed came from a keep-alive, not + the change; it is recorded with that delay in ``late_changes`` rather than raised, because a handler would swallow the error as a failed turn. """ loop = asyncio.get_running_loop() - count = len(self.activity_bodies) + index = len(self.activity_bodies) observed_at = loop.time() await observation - await self.bodies_reach(count + 1) + body = await self.body_at(index) if waited := loop.time() - observed_at: - self.late_changes.append(waited) + self.late_changes.append((body, waited)) def answer(self, request: httpx.Request) -> httpx.Response: tail = request.url.path.rsplit("/", 1)[-1] diff --git a/tests/runtime/activityownership/test_control.py b/tests/runtime/activityownership/test_control.py index 8aca8c272..d925bc5a4 100644 --- a/tests/runtime/activityownership/test_control.py +++ b/tests/runtime/activityownership/test_control.py @@ -12,7 +12,11 @@ from band.runtime.tools.agent import AgentTools from tests.activitysupport import IDLE_BODY, REASONING_BODY, tool_body from tests.conftest import make_message_event -from tests.runtime.activityownership.kit import ActivityPlatform, past_a_keep_alive +from tests.runtime.activityownership.kit import ( + ActivityPlatform, + ObservedIdle, + past_a_keep_alive, +) @pytest.mark.looptime @@ -92,3 +96,66 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: tool_body("Read"), IDLE_BODY, ] + + async def test_graceful_stop_keeps_the_draining_turn_detail_current( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + captured: list[AgentTools] = [] + handler_started = asyncio.Event() + release = asyncio.Event() + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + captured.append(AgentTools.from_context(ctx)) + handler_started.set() + await release.wait() + + ctx = execution(handler) + idle = ctx._idle_event = ObservedIdle() + await ctx.start() + await ctx.on_event(make_message_event()) + await handler_started.wait() + stopping = asyncio.create_task(ctx.stop(timeout=30)) + await idle.awaited.wait() + await activity_platform.shown(report_tool_activity(captured[0], "Bash")) + release.set() + assert await stopping + assert activity_platform.activity_bodies == [ + REASONING_BODY, + tool_body("Bash"), + IDLE_BODY, + ] + + async def test_an_interrupted_turn_cannot_change_detail_while_unwinding( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + cleanup_entered = asyncio.Event() + cleanup_release = asyncio.Event() + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + await activity_platform.shown(report_tool_activity(tools, "Bash")) + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + await report_memory_activity(tools) + cleanup_entered.set() + await cleanup_release.wait() + raise + + ctx = execution(handler) + turn = asyncio.create_task(ctx._process_event(make_message_event())) + await activity_platform.bodies_reach(2) + ctx.interrupt() + await cleanup_entered.wait() + await past_a_keep_alive() + cleanup_release.set() + await turn + assert activity_platform.activity_bodies == [ + REASONING_BODY, + tool_body("Bash"), + IDLE_BODY, + ] diff --git a/tests/runtime/activityownership/test_delivery.py b/tests/runtime/activityownership/test_delivery.py new file mode 100644 index 000000000..1e7945693 --- /dev/null +++ b/tests/runtime/activityownership/test_delivery.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +import pytest + +from band.platform.event import PlatformEvent +from band.runtime.activity import report_tool_activity +from band.runtime.execution import ExecutionContext +from band.runtime.tools.agent import AgentTools +from tests.activitysupport import tool_body +from tests.conftest import make_message_event +from tests.runtime.activityownership.kit import ActivityPlatform + + +@pytest.mark.looptime +class TestNonBlockingDelivery: + async def test_a_tool_never_waits_for_the_platform( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + observed_before_delivery: list[bool] = [] + seconds_until_sent: list[float] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + loop = asyncio.get_running_loop() + activity_platform.hold_next_activity = True + observed_at = loop.time() + observing = asyncio.create_task(report_tool_activity(tools, "Bash")) + await activity_platform.activity_entered.wait() + seconds_until_sent.append(loop.time() - observed_at) + observed_before_delivery.append(observing.done()) + activity_platform.activity_release.set() + + ctx = execution(handler) + await ctx._process_event(make_message_event()) + assert observed_before_delivery == [True] + assert seconds_until_sent == [0] + assert activity_platform.activity_bodies[1] == tool_body("Bash") diff --git a/tests/runtime/activityownership/test_detail.py b/tests/runtime/activityownership/test_detail.py index 87ffab68d..129ac2234 100644 --- a/tests/runtime/activityownership/test_detail.py +++ b/tests/runtime/activityownership/test_detail.py @@ -23,16 +23,11 @@ tool_body, ) from tests.conftest import make_message_event -from tests.runtime.activityownership.kit import ( - ActivityPlatform, - ObservedIdle, - past_a_keep_alive, -) -from tests.runtime.helpers import ROOM_ID +from tests.runtime.activityownership.kit import ActivityPlatform, past_a_keep_alive +@pytest.mark.looptime class TestOwnedDetail: - @pytest.mark.looptime async def test_a_turn_through_a_permission_wait_reports_each_visible_step( self, execution: Callable[..., ExecutionContext], @@ -62,7 +57,6 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: IDLE_BODY, ] - @pytest.mark.looptime async def test_a_settled_owner_keeps_reporting_its_worker_detail( self, execution: Callable[..., ExecutionContext], @@ -82,13 +76,14 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: await ctx._process_event(make_message_event()) owner = captured[0] await activity_platform.shown(set_permission_wait(owner, True)) - await activity_platform.bodies_reach(len(activity_platform.activity_bodies) + 1) + # The keep-alive repeats PERMISSION while the wait holds. + await activity_platform.next_body() # Hidden behind the wait: the room keeps seeing permission. await report_tool_activity(owner, "Edit") await activity_platform.shown(set_permission_wait(owner, False)) release.set() await workers[0].task - await activity_platform.bodies_reach(len(activity_platform.activity_bodies) + 1) + await activity_platform.next_body() await ctx._join_activity_completion() await end_tool_activity(owner) await past_a_keep_alive() @@ -101,165 +96,6 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: IDLE_BODY, ] - @pytest.mark.looptime - async def test_a_busy_delivery_never_changes_the_owner_detail( - self, - execution: Callable[..., ExecutionContext], - activity_platform: ActivityPlatform, - ) -> None: - release = asyncio.Event() - workers: list[TurnTask] = [] - - async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: - tools = AgentTools.from_context(ctx) - if workers: - await report_tool_activity(tools, "unrelated") - await set_permission_wait(tools, True) - return - await activity_platform.shown(report_tool_activity(tools, "Bash")) - workers.append(bind_turn_task(tools, asyncio.create_task(release.wait()))) - - ctx = execution(handler) - await ctx._process_event(make_message_event(msg_id="owner")) - await ctx._process_event(make_message_event(msg_id="busy")) - await activity_platform.bodies_reach(len(activity_platform.activity_bodies) + 1) - release.set() - await workers[0].task - await activity_platform.bodies_reach(len(activity_platform.activity_bodies) + 1) - assert activity_platform.activity_bodies == [ - REASONING_BODY, - tool_body("Bash"), - tool_body("Bash"), - IDLE_BODY, - ] - - @pytest.mark.looptime - async def test_settled_turn_cannot_restart_reporting( - self, - execution: Callable[..., ExecutionContext], - activity_platform: ActivityPlatform, - ) -> None: - captured: list[AgentTools] = [] - - async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: - captured.append(AgentTools.from_context(ctx)) - - ctx = execution(handler) - await ctx._process_event(make_message_event()) - await ctx._join_activity_completion() - before = list(activity_platform.activity_bodies) - await report_tool_activity(captured[0], "Bash") - await past_a_keep_alive() - assert before[-1] == IDLE_BODY - assert activity_platform.activity_bodies == before - - @pytest.mark.looptime - async def test_graceful_stop_keeps_the_draining_turn_detail_current( - self, - execution: Callable[..., ExecutionContext], - activity_platform: ActivityPlatform, - ) -> None: - captured: list[AgentTools] = [] - handler_started = asyncio.Event() - release = asyncio.Event() - - async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: - captured.append(AgentTools.from_context(ctx)) - handler_started.set() - await release.wait() - - ctx = execution(handler) - idle = ctx._idle_event = ObservedIdle() - await ctx.start() - await ctx.on_event(make_message_event()) - await handler_started.wait() - stopping = asyncio.create_task(ctx.stop(timeout=30)) - await idle.awaited.wait() - await activity_platform.shown(report_tool_activity(captured[0], "Bash")) - release.set() - assert await stopping - assert activity_platform.activity_bodies == [ - REASONING_BODY, - tool_body("Bash"), - IDLE_BODY, - ] - - @pytest.mark.looptime - async def test_an_interrupted_turn_cannot_change_detail_while_unwinding( - self, - execution: Callable[..., ExecutionContext], - activity_platform: ActivityPlatform, - ) -> None: - cleanup_entered = asyncio.Event() - cleanup_release = asyncio.Event() - - async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: - tools = AgentTools.from_context(ctx) - await activity_platform.shown(report_tool_activity(tools, "Bash")) - try: - await asyncio.Event().wait() - except asyncio.CancelledError: - await report_memory_activity(tools) - cleanup_entered.set() - await cleanup_release.wait() - raise - - ctx = execution(handler) - turn = asyncio.create_task(ctx._process_event(make_message_event())) - await activity_platform.bodies_reach(2) - ctx.interrupt() - await cleanup_entered.wait() - # Less than the cleanup grace and the keep-alive: only a change could post. - await asyncio.sleep(0.5) - cleanup_release.set() - await turn - assert activity_platform.activity_bodies == [ - REASONING_BODY, - tool_body("Bash"), - IDLE_BODY, - ] - - @pytest.mark.looptime - async def test_observation_from_a_waiting_delivery_never_starts_reporting( - self, - execution: Callable[..., ExecutionContext], - activity_platform: ActivityPlatform, - ) -> None: - worker_release = asyncio.Event() - waiting_tools: list[AgentTools] = [] - waiting_started = asyncio.Event() - waiting_release = asyncio.Event() - workers: list[TurnTask] = [] - - async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: - tools = AgentTools.from_context(ctx) - if workers: - waiting_tools.append(tools) - waiting_started.set() - await waiting_release.wait() - return - workers.append( - bind_turn_task(tools, asyncio.create_task(worker_release.wait())) - ) - - ctx = execution(handler) - await ctx._process_event(make_message_event(msg_id="owner")) - waiting = asyncio.create_task( - ctx._process_event(make_message_event(msg_id="waiting")) - ) - await waiting_started.wait() - worker_release.set() - await workers[0].task - await ctx._join_activity_completion() - before = list(activity_platform.activity_bodies) - await report_tool_activity(waiting_tools[0], "Bash") - await past_a_keep_alive() - assert before[-1] == IDLE_BODY - assert activity_platform.activity_bodies == before - waiting_release.set() - await waiting - - @pytest.mark.looptime async def test_a_new_turn_starts_at_reasoning( self, execution: Callable[..., ExecutionContext], @@ -286,52 +122,3 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: REASONING_BODY, IDLE_BODY, ] - - @pytest.mark.looptime - async def test_a_tool_never_waits_for_the_platform( - self, - execution: Callable[..., ExecutionContext], - activity_platform: ActivityPlatform, - ) -> None: - observed_before_delivery: list[bool] = [] - seconds_until_sent: list[float] = [] - - async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: - tools = AgentTools.from_context(ctx) - loop = asyncio.get_running_loop() - activity_platform.hold_next_activity = True - observed_at = loop.time() - observing = asyncio.create_task(report_tool_activity(tools, "Bash")) - await activity_platform.activity_entered.wait() - seconds_until_sent.append(loop.time() - observed_at) - observed_before_delivery.append(observing.done()) - activity_platform.activity_release.set() - - ctx = execution(handler) - await ctx._process_event(make_message_event()) - assert observed_before_delivery == [True] - assert seconds_until_sent == [0] - assert activity_platform.activity_bodies[1] == tool_body("Bash") - - @pytest.mark.parametrize("excluded", ["disabled", "hub"]) - async def test_excluded_reporting_ignores_observations( - self, - execution: Callable[..., ExecutionContext], - activity_platform: ActivityPlatform, - excluded: str, - ) -> None: - async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: - tools = AgentTools.from_context(ctx) - await report_tool_activity(tools, "Bash") - await set_permission_wait(tools, True) - await report_memory_activity(tools) - await end_tool_activity(tools) - - settings = ( - {"enable_working_state": False} - if excluded == "disabled" - else {"hub_room_id": ROOM_ID} - ) - ctx = execution(handler, **settings) - await ctx._process_event(make_message_event()) - assert not activity_platform.activity_bodies diff --git a/tests/runtime/activityownership/test_exclusion.py b/tests/runtime/activityownership/test_exclusion.py new file mode 100644 index 000000000..ffcd7ccf1 --- /dev/null +++ b/tests/runtime/activityownership/test_exclusion.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from band.platform.event import PlatformEvent +from band.runtime.activity import ( + end_tool_activity, + report_memory_activity, + report_tool_activity, + set_permission_wait, +) +from band.runtime.execution import ExecutionContext +from band.runtime.tools.agent import AgentTools +from tests.conftest import make_message_event +from tests.runtime.activityownership.kit import ActivityPlatform, past_a_keep_alive +from tests.runtime.helpers import ROOM_ID + + +@pytest.mark.looptime +class TestExcludedReporting: + @pytest.mark.parametrize( + "settings", + [{"enable_working_state": False}, {"hub_room_id": ROOM_ID}], + ids=["disabled", "hub"], + ) + async def test_excluded_reporting_ignores_observations( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + settings: dict[str, object], + ) -> None: + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + await report_tool_activity(tools, "Bash") + await set_permission_wait(tools, True) + await report_memory_activity(tools) + await end_tool_activity(tools) + + ctx = execution(handler, **settings) + await ctx._process_event(make_message_event()) + await past_a_keep_alive() + assert not activity_platform.activity_bodies diff --git a/tests/runtime/activityownership/test_nonowner.py b/tests/runtime/activityownership/test_nonowner.py new file mode 100644 index 000000000..e13b7bc67 --- /dev/null +++ b/tests/runtime/activityownership/test_nonowner.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +import pytest + +from band.core.turn_lifecycle import TurnTask, bind_turn_task +from band.platform.event import PlatformEvent +from band.runtime.activity import report_tool_activity, set_permission_wait +from band.runtime.execution import ExecutionContext +from band.runtime.tools.agent import AgentTools +from tests.activitysupport import IDLE_BODY, REASONING_BODY, tool_body +from tests.conftest import make_message_event +from tests.runtime.activityownership.kit import ActivityPlatform, past_a_keep_alive + + +@pytest.mark.looptime +class TestNonOwnerObservations: + async def test_a_busy_delivery_never_changes_the_owner_detail( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + release = asyncio.Event() + workers: list[TurnTask] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + if workers: + await report_tool_activity(tools, "unrelated") + await set_permission_wait(tools, True) + return + await activity_platform.shown(report_tool_activity(tools, "Bash")) + workers.append(bind_turn_task(tools, asyncio.create_task(release.wait()))) + + ctx = execution(handler) + await ctx._process_event(make_message_event(msg_id="owner")) + await ctx._process_event(make_message_event(msg_id="busy")) + await activity_platform.next_body() + release.set() + await workers[0].task + await activity_platform.next_body() + assert activity_platform.activity_bodies == [ + REASONING_BODY, + tool_body("Bash"), + tool_body("Bash"), + IDLE_BODY, + ] + + async def test_settled_turn_cannot_restart_reporting( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + captured: list[AgentTools] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + captured.append(AgentTools.from_context(ctx)) + + ctx = execution(handler) + await ctx._process_event(make_message_event()) + await ctx._join_activity_completion() + before = list(activity_platform.activity_bodies) + await report_tool_activity(captured[0], "Bash") + await past_a_keep_alive() + assert before[-1] == IDLE_BODY + assert activity_platform.activity_bodies == before + + async def test_observation_from_a_waiting_delivery_never_starts_reporting( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + worker_release = asyncio.Event() + waiting_tools: list[AgentTools] = [] + waiting_started = asyncio.Event() + waiting_release = asyncio.Event() + workers: list[TurnTask] = [] + + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + if workers: + waiting_tools.append(tools) + waiting_started.set() + await waiting_release.wait() + return + workers.append( + bind_turn_task(tools, asyncio.create_task(worker_release.wait())) + ) + + ctx = execution(handler) + await ctx._process_event(make_message_event(msg_id="owner")) + waiting = asyncio.create_task( + ctx._process_event(make_message_event(msg_id="waiting")) + ) + await waiting_started.wait() + worker_release.set() + await workers[0].task + await ctx._join_activity_completion() + before = list(activity_platform.activity_bodies) + await report_tool_activity(waiting_tools[0], "Bash") + await past_a_keep_alive() + assert before[-1] == IDLE_BODY + assert activity_platform.activity_bodies == before + waiting_release.set() + await waiting diff --git a/tests/runtime/activityownership/test_observerless.py b/tests/runtime/activityownership/test_observerless.py index ab8e9ee59..32e068ce5 100644 --- a/tests/runtime/activityownership/test_observerless.py +++ b/tests/runtime/activityownership/test_observerless.py @@ -1,16 +1,24 @@ from __future__ import annotations +import pytest + from band.runtime.tools.agent import AgentTools from band.testing import FakeAgentTools -from tests.runtime.activityownership.kit import ActivityPlatform, report_every_kind +from tests.runtime.activityownership.kit import ( + ActivityPlatform, + past_a_keep_alive, + report_every_kind, +) +@pytest.mark.looptime class TestObserverlessProducers: - async def test_producers_accept_tools_that_carry_no_observer(self) -> None: - await report_every_kind(FakeAgentTools()) - async def test_tools_outside_an_execution_report_nothing( self, bare_tools: AgentTools, activity_platform: ActivityPlatform ) -> None: + fake = FakeAgentTools() + await report_every_kind(fake) await report_every_kind(bare_tools) + await past_a_keep_alive() assert activity_platform.activity_bodies == [] + assert fake.tool_calls == [] From a2bb026390ade5cc8b1ca7903023e16a5da05805 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 18:34:45 +0300 Subject: [PATCH 28/33] test: avoid repeated interpreter startup in CI coverage guard --- .../test_collected_coverage.py | 40 ++++++++-- .../test_strands_nooa_job_coverage.py | 8 +- .../venv_job_coverage.py | 73 +++++-------------- 3 files changed, 58 insertions(+), 63 deletions(-) diff --git a/tests/framework_conformance/test_collected_coverage.py b/tests/framework_conformance/test_collected_coverage.py index 8c416952c..1e42e438f 100644 --- a/tests/framework_conformance/test_collected_coverage.py +++ b/tests/framework_conformance/test_collected_coverage.py @@ -2,8 +2,11 @@ from __future__ import annotations +import sys from pathlib import Path +import pytest + from tests.framework_conformance.venv_job_coverage import ( collected_nodes, selected_nodes, @@ -12,7 +15,7 @@ class TestCollectedCoverage: def test_keyword_filter_does_not_cover_an_explicitly_named_file( - self, tmp_path: Path + self, tmp_path: Path, pytester: pytest.Pytester ) -> None: test = tmp_path / "test_selection.py" test.write_text( @@ -20,12 +23,12 @@ def test_keyword_filter_does_not_cover_an_explicitly_named_file( " def test_retained(self): pass\n" " def test_omitted(self): pass\n" ) - full = selected_nodes([str(test)]) - selected = selected_nodes([str(test), "-k", "retained"]) + full = selected_nodes(pytester, [str(test)]) + selected = selected_nodes(pytester, [str(test), "-k", "retained"]) assert {node.rsplit("::", 1)[1] for node in full - selected} == {"test_omitted"} def test_framework_binding_does_not_depend_on_the_test_name( - self, tmp_path: Path + self, tmp_path: Path, pytester: pytest.Pytester ) -> None: test = tmp_path / "test_bindings.py" test.write_text( @@ -34,8 +37,35 @@ def test_framework_binding_does_not_depend_on_the_test_name( ' @pytest.mark.parametrize("framework_id", ["strands", "pydantic_ai"])\n' " def test_behavior(self, framework_id): pass\n" ) - nodes = collected_nodes([str(test)]) + nodes = collected_nodes(pytester, [str(test)]) assert {node.frameworks for node in nodes} == { frozenset({"strands"}), frozenset({"pydantic_ai"}), } + + def test_ignored_file_does_not_count_as_covered( + self, pytester: pytest.Pytester + ) -> None: + pytester.makeini("[pytest]") + retained = pytester.makepyfile(test_retained="def test_behavior(): pass") + omitted = pytester.makepyfile(test_omitted="def test_behavior(): pass") + full = selected_nodes(pytester, [str(pytester.path)]) + selected = selected_nodes( + pytester, [str(pytester.path), "--ignore", str(omitted)] + ) + assert {node.split("::", 1)[0] for node in full - selected} == {omitted.name} + assert {node.split("::", 1)[0] for node in selected} == {retained.name} + + def test_collection_restores_imported_modules( + self, pytester: pytest.Pytester + ) -> None: + test = pytester.makepyfile(test_isolated_import="def test_behavior(): pass") + assert selected_nodes(pytester, [str(test)]) + assert test.stem not in sys.modules + + def test_collection_errors_fail_the_coverage_check( + self, pytester: pytest.Pytester + ) -> None: + test = pytester.makepyfile('raise RuntimeError("collection failed")') + with pytest.raises(AssertionError): + collected_nodes(pytester, [str(test)]) diff --git a/tests/framework_conformance/test_strands_nooa_job_coverage.py b/tests/framework_conformance/test_strands_nooa_job_coverage.py index d639dd29e..71982e606 100644 --- a/tests/framework_conformance/test_strands_nooa_job_coverage.py +++ b/tests/framework_conformance/test_strands_nooa_job_coverage.py @@ -32,7 +32,9 @@ def test_expected_configs_are_present(self, framework_environment: None) -> None assert expected <= {config.framework_id for config in ADAPTER_CONFIGS} assert expected <= {config.framework_id for config in CONVERTER_CONFIGS} - def test_job_selects_every_required_node(self, framework_environment: None) -> None: + def test_job_selects_every_required_node( + self, framework_environment: None, pytester: pytest.Pytester + ) -> None: needed = coverage.tests_needing_venv( coverage.needs_venv_pattern(frozenset({"strands", "nooa"})) ) @@ -41,7 +43,7 @@ def test_job_selects_every_required_node(self, framework_environment: None) -> N arguments = coverage.pytest_arguments( coverage.job_command("test-strands-nooa", "Run Strands and NOOA tests") ) - selected = coverage.selected_nodes(arguments) + selected = coverage.selected_nodes(pytester, arguments) # Directory collection honors NOOA's supported-platform conftest gate. nooa_tests = REPO_ROOT / "tests/adapters/nooa" targets = { @@ -49,7 +51,7 @@ def test_job_selects_every_required_node(self, framework_environment: None) -> N for path in needed } baseline = coverage.collected_nodes( - [*sorted(targets), "tests/framework_conformance/"] + pytester, [*sorted(targets), "tests/framework_conformance/"] ) needed_files = {path.as_posix() for path in needed} required = { diff --git a/tests/framework_conformance/venv_job_coverage.py b/tests/framework_conformance/venv_job_coverage.py index e5a59ee79..bc8ef1e66 100644 --- a/tests/framework_conformance/venv_job_coverage.py +++ b/tests/framework_conformance/venv_job_coverage.py @@ -3,16 +3,12 @@ from __future__ import annotations import ast -import json import re import shlex -import subprocess -import sys -import tempfile import tomllib from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, cast import pytest import yaml @@ -135,34 +131,26 @@ class CollectedNode: skipped: bool -def collected_nodes(arguments: list[str]) -> tuple[CollectedNode, ...]: +def collected_nodes( + pytester: pytest.Pytester, arguments: list[str] +) -> tuple[CollectedNode, ...]: """Collect nodes and framework bindings after pytest applies the job selectors.""" - with tempfile.TemporaryDirectory() as directory: - output = Path(directory) / "nodes.json" - result = subprocess.run( - [ - sys.executable, - "-m", - "tests.framework_conformance.venv_job_coverage", - str(output), - *arguments, - ], - cwd=REPO_ROOT, - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - raise AssertionError(result.stdout + result.stderr) - return tuple( - CollectedNode(row["nodeid"], frozenset(row["frameworks"]), row["skipped"]) - for row in json.loads(output.read_text(encoding="utf-8")) + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.chdir(REPO_ROOT) + recorder = pytester.inline_run(*arguments, "--collect-only", "--no-cov") + assert recorder.ret == pytest.ExitCode.OK + session = cast(pytest.Session, recorder.getcall("pytest_collection_finish").session) + return tuple( + CollectedNode( + item.nodeid, frozenset(framework_bindings(item)), is_skipped(item) ) + for item in session.items + ) -def selected_nodes(arguments: list[str]) -> set[str]: +def selected_nodes(pytester: pytest.Pytester, arguments: list[str]) -> set[str]: """The exact node IDs surviving a job's pytest selectors.""" - return {node.nodeid for node in collected_nodes(arguments)} + return {node.nodeid for node in collected_nodes(pytester, arguments)} def framework_bindings(item: pytest.Item) -> set[str]: @@ -175,38 +163,13 @@ def framework_bindings(item: pytest.Item) -> set[str]: } -def collection_row(item: pytest.Item) -> dict[str, object]: - skipped = item.get_closest_marker("skip") is not None or any( +def is_skipped(item: pytest.Item) -> bool: + return item.get_closest_marker("skip") is not None or any( marker.args and marker.args[0] is True for marker in item.iter_markers("skipif") ) - return { - "nodeid": item.nodeid, - "frameworks": sorted(framework_bindings(item)), - "skipped": skipped, - } def pytest_arguments(command: str) -> list[str]: """Read selectors after the workflow's pytest executable.""" tokens = shlex.split(command) return tokens[tokens.index("pytest") + 1 :] - - -class NodeCollector: - def __init__(self, output: Path) -> None: - self.output = output - - def pytest_collection_finish(self, session: pytest.Session) -> None: - self.output.write_text( - json.dumps([collection_row(item) for item in session.items]), - encoding="utf-8", - ) - - -if __name__ == "__main__": - raise SystemExit( - pytest.main( - [*sys.argv[2:], "--collect-only", "--no-cov"], - plugins=[NodeCollector(Path(sys.argv[1]))], - ) - ) From 3f59acff272e7ca833f04222e56ea8a8b97be0e9 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 14:44:27 +0300 Subject: [PATCH 29/33] feat: Report Band tool calls as turn activity Band tool methods show as tool_call with their name, or memory while saving a memory, and return to reasoning when they end, even on failure or cancellation. tool_activity resolves the turn observer once, so a tool ends on the turn it started on. send_event reports only where a model invoked it (execute_tool_call, MCP, Pydantic AI, CrewAI, Parlant). CrewAI and Parlant custom tools report too. A tools object without a declared observer stays unreported instead of conjuring one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- .github/workflows/ci.yml | 2 + docs/turn-outcome.md | 7 ++ .../integrations/claude_sdk/dedup_tools.py | 6 ++ src/band/integrations/crewai/catalog.py | 8 +- src/band/integrations/crewai/tools.py | 8 +- src/band/integrations/mcp/engine.py | 4 +- .../integrations/parlant/bandtools/chat.py | 6 +- src/band/integrations/parlant/customtools.py | 14 +-- src/band/integrations/pydantic_ai/tools.py | 4 +- src/band/integrations/slack/adapter.py | 1 + src/band/runtime/activity.py | 16 ++-- src/band/runtime/tools/agent.py | 10 ++- src/band/runtime/turn.py | 80 ++++++++++++++++- src/band/testing/activity.py | 26 ++++++ src/band/testing/fake_tools.py | 2 + tests/adapters/activity/__init__.py | 1 + tests/adapters/activity/test_model_event.py | 43 +++++++++ tests/agent/test_startup_race_condition.py | 34 +++----- .../test_crewai_job_coverage.py | 1 + .../test_parlant_job_coverage.py | 1 + tests/integrations/crewai/__init__.py | 1 + .../integrations/crewai/activity/__init__.py | 1 + tests/integrations/crewai/activity/kit.py | 9 ++ .../crewai/activity/test_execution.py | 44 ++++++++++ .../integrations/parlant/activity/__init__.py | 1 + tests/integrations/parlant/activity/kit.py | 9 ++ .../parlant/activity/test_execution.py | 87 +++++++++++++++++++ .../runtime/activityownership/test_detail.py | 2 + .../activityownership/test_housekeeping.py | 24 +++++ .../test_opencode_admission.py | 6 +- tests/runtime/helpers.py | 9 ++ tests/runtime/toolactivity/__init__.py | 1 + tests/runtime/toolactivity/conftest.py | 22 +++++ tests/runtime/toolactivity/test_band_tools.py | 44 ++++++++++ .../toolactivity/test_cancelled_tool.py | 83 ++++++++++++++++++ .../runtime/toolactivity/test_memory_tools.py | 60 +++++++++++++ .../toolactivity/test_rebound_tools.py | 37 ++++++++ .../toolactivity/test_undeclared_observer.py | 26 ++++++ .../test_agent_tools_context_sync_back.py | 69 +++------------ 39 files changed, 700 insertions(+), 109 deletions(-) create mode 100644 src/band/testing/activity.py create mode 100644 tests/adapters/activity/__init__.py create mode 100644 tests/adapters/activity/test_model_event.py create mode 100644 tests/integrations/crewai/__init__.py create mode 100644 tests/integrations/crewai/activity/__init__.py create mode 100644 tests/integrations/crewai/activity/kit.py create mode 100644 tests/integrations/crewai/activity/test_execution.py create mode 100644 tests/integrations/parlant/activity/__init__.py create mode 100644 tests/integrations/parlant/activity/kit.py create mode 100644 tests/integrations/parlant/activity/test_execution.py create mode 100644 tests/runtime/activityownership/test_housekeeping.py create mode 100644 tests/runtime/toolactivity/__init__.py create mode 100644 tests/runtime/toolactivity/conftest.py create mode 100644 tests/runtime/toolactivity/test_band_tools.py create mode 100644 tests/runtime/toolactivity/test_cancelled_tool.py create mode 100644 tests/runtime/toolactivity/test_memory_tools.py create mode 100644 tests/runtime/toolactivity/test_rebound_tools.py create mode 100644 tests/runtime/toolactivity/test_undeclared_observer.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed44441e7..f3997dc7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,7 @@ jobs: tests/integrations/test_crewai_flow_real_sdk.py \ tests/integrations/test_crewai_real_tools.py \ tests/integrations/test_crewai_completion.py \ + tests/integrations/crewai/activity/test_execution.py \ tests/test_capability_gating_e2e.py \ tests/framework_conformance/test_files_image_passthrough_matrix.py \ tests/framework_conformance/ -k crewai @@ -325,6 +326,7 @@ jobs: tests/integrations/parlant/test_customtools.py \ tests/integrations/parlant/test_filetools.py \ tests/integrations/parlant/test_guard.py \ + tests/integrations/parlant/activity/test_execution.py \ tests/integrations/parlant/test_preambletask.py \ tests/integrations/parlant/test_selection.py \ tests/integrations/parlant/test_server.py \ diff --git a/docs/turn-outcome.md b/docs/turn-outcome.md index 87c0e7a50..c26443488 100644 --- a/docs/turn-outcome.md +++ b/docs/turn-outcome.md @@ -435,6 +435,13 @@ change wakes the reporter's keep-alive, which sends the latest detail, so changes made while a report is in flight coalesce. The duration cap stops both kinds of refresh without cancelling provider work; actual completion still clears. +Every Band tool call shows as a `tool_call` with its name, or `memory` while it +saves a memory, then returns to reasoning when it ends, even on failure or +cancellation. Wrap any other tool call in `band.runtime.turn.tool_activity`. +`send_event` reports only where a model invoked it, since adapters also post +their own events through it. A tool's end lands on the turn it started on, even +if its tools are rebound to a newer turn meanwhile. + Detail compatibility is learned from the `X-API-Version` header the platform puts on every activity response. Reports stay boolean until a response in the process advertises agent API 1.25.0 or later; a missing or older version keeps diff --git a/src/band/integrations/claude_sdk/dedup_tools.py b/src/band/integrations/claude_sdk/dedup_tools.py index 70f9716f4..70ca238a7 100644 --- a/src/band/integrations/claude_sdk/dedup_tools.py +++ b/src/band/integrations/claude_sdk/dedup_tools.py @@ -42,6 +42,8 @@ from collections import OrderedDict from typing import TYPE_CHECKING, Any +from band.runtime.activity import ActivityObserver, activity_observer_of + if TYPE_CHECKING: from band.core.protocols import AgentToolsProtocol @@ -199,6 +201,10 @@ async def send_message( # --- transparent passthrough -------------------------------------- + @property + def _activity_observer(self) -> ActivityObserver | None: + return activity_observer_of(self._inner) + def __getattr__(self, name: str) -> Any: # ``__getattr__`` only runs for attributes not found via the # normal MRO. ``send_message`` is defined above, so it never diff --git a/src/band/integrations/crewai/catalog.py b/src/band/integrations/crewai/catalog.py index e33df22f1..1946bce0c 100644 --- a/src/band/integrations/crewai/catalog.py +++ b/src/band/integrations/crewai/catalog.py @@ -29,6 +29,7 @@ serialize_tool_result, validate_tool_arguments, ) +from band.runtime.turn import tool_activity # --- Rendering a tool result as the text CrewAI hands the model --- @@ -288,9 +289,10 @@ async def _send_event(call: Invocation, **kwargs: Any) -> Any: # bypassing args_schema, and _execute_tool turns the ValueError into the # error result the caller expects. args = validate_tool_arguments(BandTool.SEND_EVENT, SEND_EVENT_ARGS_SCHEMA, kwargs) - await call.tools.send_event( - args["content"], args["message_type"], metadata=args.get("metadata") - ) + async with tool_activity(call.tools, BandTool.SEND_EVENT): + await call.tools.send_event( + args["content"], args["message_type"], metadata=args.get("metadata") + ) return succeeded("Event sent") diff --git a/src/band/integrations/crewai/tools.py b/src/band/integrations/crewai/tools.py index 104e1a573..7b480efd1 100644 --- a/src/band/integrations/crewai/tools.py +++ b/src/band/integrations/crewai/tools.py @@ -61,6 +61,7 @@ get_tool_description, withheld_tool_names, ) +from band.runtime.turn import tool_activity logger = logging.getLogger(__name__) @@ -219,9 +220,10 @@ class CustomCrewAITool(BaseTool): def _run(self, *_args: Any, **kwargs: Any) -> Any: async def execute(tools: AgentToolsProtocol) -> str: await reporter.report_call(tools, tool_name, kwargs) - result = await execute_custom_tool( - tool=definition, arguments=kwargs, turn=tools.turn - ) + async with tool_activity(tools, tool_name): + result = await execute_custom_tool( + tool=definition, arguments=kwargs, turn=tools.turn + ) await reporter.report_result(tools, tool_name, result) return json.dumps({"status": "success", "result": result}, default=str) diff --git a/src/band/integrations/mcp/engine.py b/src/band/integrations/mcp/engine.py index c89c6ea4f..a542c7b49 100644 --- a/src/band/integrations/mcp/engine.py +++ b/src/band/integrations/mcp/engine.py @@ -64,6 +64,7 @@ ) from band.runtime.tools.inputs.chat import require_visible_content from band.runtime.tools.inputs.identifiers import ResourceId +from band.runtime.turn import model_tool_activity logger = logging.getLogger(__name__) @@ -219,7 +220,8 @@ async def dispatch_tool( """ method = resolve_tool_method(tools, definition) try: - return await method(**arguments) + async with model_tool_activity(tools, definition.name): + return await method(**arguments) except (ValueError, BandToolError) as error: raise enrich_send_message_error(definition, tools, error) from error diff --git a/src/band/integrations/parlant/bandtools/chat.py b/src/band/integrations/parlant/bandtools/chat.py index 6886419d2..6a4a02035 100644 --- a/src/band/integrations/parlant/bandtools/chat.py +++ b/src/band/integrations/parlant/bandtools/chat.py @@ -15,7 +15,8 @@ split_mentions, ) from band.integrations.parlant.sessiontools import require_session_tools -from band.runtime.tools import ParticipantAddStatus, serialize_tool_result +from band.runtime.tools import BandTool, ParticipantAddStatus, serialize_tool_result +from band.runtime.turn import tool_activity EVENT_MESSAGE_TYPES: tuple[str, ...] = get_args(EventMessageType) @@ -59,7 +60,8 @@ async def band_send_event( if message_type not in EVENT_MESSAGE_TYPES: return invalid_choice("message_type", message_type, EVENT_MESSAGE_TYPES) - await tools.send_event(content, message_type, None) + async with tool_activity(tools, BandTool.SEND_EVENT): + await tools.send_event(content, message_type, None) return ToolResult(data=f"Event ({message_type}) sent successfully") diff --git a/src/band/integrations/parlant/customtools.py b/src/band/integrations/parlant/customtools.py index 0f5c1f8d9..1b1b7142e 100644 --- a/src/band/integrations/parlant/customtools.py +++ b/src/band/integrations/parlant/customtools.py @@ -17,6 +17,7 @@ ) from band.runtime.custom_tools import CustomToolDef, execute_custom_tool from band.runtime.tools import tool_result_text +from band.runtime.turn import tool_activity # Parlant delivers every argument as a string, which only lax validation # converts, so even a strict field is validated laxly. @@ -30,12 +31,13 @@ def build_custom_tool(tool_def: CustomToolDef) -> Any: async def run(context: ToolContext, **arguments: Any) -> ToolResult: tools = require_session_tools(context) - result = await execute_custom_tool( - tool_def, - parse_delivered(arguments, described.fields), - turn=tools.turn, - strict=STRICT_VALIDATION, - ) + async with tool_activity(tools, described.name): + result = await execute_custom_tool( + tool_def, + parse_delivered(arguments, described.fields), + turn=tools.turn, + strict=STRICT_VALIDATION, + ) return ToolResult(data=tool_result_text(result)) run.__name__ = described.name diff --git a/src/band/integrations/pydantic_ai/tools.py b/src/band/integrations/pydantic_ai/tools.py index a9d724b3b..349c6d06c 100644 --- a/src/band/integrations/pydantic_ai/tools.py +++ b/src/band/integrations/pydantic_ai/tools.py @@ -37,6 +37,7 @@ turn_effect, validate_tool_arguments, ) +from band.runtime.turn import model_tool_activity logger = logging.getLogger(__name__) @@ -202,7 +203,8 @@ async def dispatch(ctx: RunContext[AgentToolsProtocol], **arguments: Any) -> Any # something to hand the model as a tool error it could act on. method = _resolve_method(ctx.deps, definition) try: - result = await method(**kwargs) + async with model_tool_activity(ctx.deps, definition.name): + result = await method(**kwargs) except Exception as error: # noqa: BLE001 -- tool calls may raise any exception type; must surface to the LLM as an error string, not crash the turn return await _dispatch_failed(ctx, definition, error, notify_room=True) try: diff --git a/src/band/integrations/slack/adapter.py b/src/band/integrations/slack/adapter.py index 2842bf067..cdc8c6412 100644 --- a/src/band/integrations/slack/adapter.py +++ b/src/band/integrations/slack/adapter.py @@ -162,6 +162,7 @@ def __init__( # adapter judging ``wrap`` sees what the brain did through this tee. self._ctx = wrap._ctx self._scope = wrap._scope + self._activity_observer = wrap._activity_observer self.turn = wrap.turn self._slack = slack self._binding = binding diff --git a/src/band/runtime/activity.py b/src/band/runtime/activity.py index 62de696b3..808fddf17 100644 --- a/src/band/runtime/activity.py +++ b/src/band/runtime/activity.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass +from inspect import getattr_static from typing import Protocol from band_rest.types.agent_activity_detail import AgentActivityDetail @@ -56,25 +57,30 @@ def apply( self.waiting = waiting -def _observer(tools: object) -> ActivityObserver | None: +def activity_observer_of(tools: object) -> ActivityObserver | None: + """The turn observer bound to ``tools``, if it declares one.""" + # A dynamic proxy (``__getattr__``, a mock) would conjure an observer it + # never had; only a declared attribute counts. + if getattr_static(tools, "_activity_observer", None) is None: + return None return getattr(tools, "_activity_observer", None) async def report_tool_activity(tools: object, name: str) -> None: - if observer := _observer(tools): + if observer := activity_observer_of(tools): await observer.update(tool_call_activity(name)) async def end_tool_activity(tools: object) -> None: - if observer := _observer(tools): + if observer := activity_observer_of(tools): await observer.update(REASONING) async def report_memory_activity(tools: object) -> None: - if observer := _observer(tools): + if observer := activity_observer_of(tools): await observer.update(MEMORY) async def set_permission_wait(tools: object, waiting: bool) -> None: - if observer := _observer(tools): + if observer := activity_observer_of(tools): await observer.set_manual_wait(waiting) diff --git a/src/band/runtime/tools/agent.py b/src/band/runtime/tools/agent.py index 9067440a0..74c3efe6c 100644 --- a/src/band/runtime/tools/agent.py +++ b/src/band/runtime/tools/agent.py @@ -74,7 +74,11 @@ serialize_tool_result, validate_tool_arguments, ) -from band.runtime.turn import records_turn_effects +from band.runtime.turn import ( + model_tool_activity, + records_turn_effects, + reports_tool_activity, +) if TYPE_CHECKING: from anthropic.types import ToolParam @@ -286,6 +290,7 @@ def cache_parameters(self) -> Any: ... @records_turn_effects +@reports_tool_activity class AgentTools(AgentToolsProtocol): """ Room-bound tools for LLM platform interaction. @@ -2042,7 +2047,8 @@ async def execute_tool_call_structured( try: method = getattr(self, definition.method_name) - result = await method(**arguments) + async with model_tool_activity(self, tool_name): + result = await method(**arguments) return ToolCallOutcome(value=serialize_tool_result(result), ok=True) except (BandToolError, RoomExecutionStoppedError): # Stopped execution must escape ordinary tool-error conversion. diff --git a/src/band/runtime/turn.py b/src/band/runtime/turn.py index c99551e48..4a37ec860 100644 --- a/src/band/runtime/turn.py +++ b/src/band/runtime/turn.py @@ -1,15 +1,24 @@ -"""Records each Band tool call's effect on the turn that made it.""" +"""Records each Band tool call's effect and activity on the turn that made it.""" from __future__ import annotations import functools -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager from typing import Any, TypeVar +from band_rest.types.agent_activity_detail import AgentActivityDetail + from band.core.turn import Turn +from band.runtime.activity import ( + MEMORY, + REASONING, + activity_observer_of, + tool_call_activity, +) from band.runtime.tools.effects import turn_effect from band.runtime.tools.registry import TOOL_DEFINITIONS -from band.runtime.tools.types import Surface, TurnEffect +from band.runtime.tools.types import BandTool, Surface, TurnEffect _ToolsClass = TypeVar("_ToolsClass", bound=type) @@ -48,3 +57,68 @@ def records_turn_effects(cls: _ToolsClass) -> _ToolsClass: if definition.surface == Surface.AGENT and method is not None: setattr(cls, definition.method_name, _recording(definition.name, method)) return cls + + +MEMORY_SAVE_TOOLS = frozenset( + {BandTool.STORE_MEMORY, BandTool.SUPERSEDE_MEMORY, BandTool.ARCHIVE_MEMORY} +) + + +def _tool_detail(name: str) -> AgentActivityDetail: + return MEMORY if name in MEMORY_SAVE_TOOLS else tool_call_activity(name) + + +@asynccontextmanager +async def tool_activity(tools: object, name: str) -> AsyncIterator[None]: + """Show ``name`` as the turn's activity until the tool ends. + + The observer is resolved once so the end lands on the turn the tool + started on, even if ``tools`` is rebound to another turn meanwhile. + """ + observer = activity_observer_of(tools) + if observer is None: + yield + return + try: + await observer.update(_tool_detail(name)) + yield + finally: + await observer.update(REASONING) + + +@asynccontextmanager +async def model_tool_activity(tools: object, name: str) -> AsyncIterator[None]: + """Report a model's tool call that the tools method itself does not. + + ``send_event`` is also the adapters' own telemetry path, so it reports + only where a model invoked it. + """ + if name != BandTool.SEND_EVENT: + yield + return + async with tool_activity(tools, name): + yield + + +def _observing( + tool_name: str, method: Callable[..., Awaitable[Any]] +) -> Callable[..., Awaitable[Any]]: + @functools.wraps(method) + async def observe(self: Any, *args: Any, **kwargs: Any) -> Any: + async with tool_activity(self, tool_name): + return await method(self, *args, **kwargs) + + return observe + + +def reports_tool_activity(cls: _ToolsClass) -> _ToolsClass: + """Report every agent tool method except ``send_event`` as turn activity.""" + for definition in TOOL_DEFINITIONS.values(): + method = cls.__dict__.get(definition.method_name) + if ( + definition.surface == Surface.AGENT + and definition.name != BandTool.SEND_EVENT + and method is not None + ): + setattr(cls, definition.method_name, _observing(definition.name, method)) + return cls diff --git a/src/band/testing/activity.py b/src/band/testing/activity.py new file mode 100644 index 000000000..2be8317c0 --- /dev/null +++ b/src/band/testing/activity.py @@ -0,0 +1,26 @@ +"""Record what a turn's activity observer is told, without reporting it.""" + +from __future__ import annotations + +from typing import Any + +from band_rest.types.agent_activity_detail import AgentActivityDetail + + +class ActivityRecorder: + """An ``ActivityObserver`` that keeps every observation in order.""" + + def __init__(self) -> None: + self.events: list[dict[str, Any]] = [] + + async def update(self, activity: AgentActivityDetail) -> None: + self.events.append(activity.model_dump(exclude_none=True)) + + async def set_manual_wait(self, waiting: bool) -> None: + self.events.append({"waiting": waiting}) + + def assert_completed_tool(self, name: str) -> None: + assert self.events == [ + {"kind": "tool_call", "name": name}, + {"kind": "reasoning"}, + ] diff --git a/src/band/testing/fake_tools.py b/src/band/testing/fake_tools.py index 35248e458..09b95e59e 100644 --- a/src/band/testing/fake_tools.py +++ b/src/band/testing/fake_tools.py @@ -66,6 +66,7 @@ ContactRequestStatus, MessageType, ) +from band.runtime.activity import ActivityObserver from band.runtime.context_serialization import context_item_to_dict from band.runtime.tools import ( DEFAULT_FILE_CAPTION, @@ -326,6 +327,7 @@ def __init__( self._held_messages: list[HeldMessage] = [] self._observers: list[tuple[Callable[[], bool], asyncio.Future[None]]] = [] self.turn = Turn() + self._activity_observer: ActivityObserver | None = None @property def agent_id(self) -> str | None: diff --git a/tests/adapters/activity/__init__.py b/tests/adapters/activity/__init__.py new file mode 100644 index 000000000..9d48db4f9 --- /dev/null +++ b/tests/adapters/activity/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/adapters/activity/test_model_event.py b/tests/adapters/activity/test_model_event.py new file mode 100644 index 000000000..9a6058b1d --- /dev/null +++ b/tests/adapters/activity/test_model_event.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest +from pydantic_ai import RunContext +from pydantic_ai.models.test import TestModel +from pydantic_ai.usage import RunUsage + +from band.integrations.mcp.engine import dispatch_tool +from band.integrations.pydantic_ai.tools import _dispatcher +from band.runtime.tools.registry import TOOL_DEFINITIONS +from band.runtime.tools.types import BandTool +from band.testing.activity import ActivityRecorder +from band.testing.fake_tools import FakeAgentTools + +SEND_EVENT = TOOL_DEFINITIONS[BandTool.SEND_EVENT] +ARGUMENTS = {"content": "model", "message_type": "thought"} + + +async def dispatch_over_mcp(tools: FakeAgentTools) -> None: + await dispatch_tool(tools, SEND_EVENT, ARGUMENTS) + + +async def dispatch_over_pydantic_ai(tools: FakeAgentTools) -> None: + dispatch = _dispatcher(SEND_EVENT, SEND_EVENT.input_model) + await dispatch( + RunContext(deps=tools, model=TestModel(), usage=RunUsage()), **ARGUMENTS + ) + + +class TestModelEventActivity: + @pytest.mark.parametrize("dispatch", [dispatch_over_mcp, dispatch_over_pydantic_ai]) + async def test_a_model_event_reports_while_raw_telemetry_stays_quiet( + self, dispatch: Callable[[FakeAgentTools], Awaitable[None]] + ) -> None: + tools = FakeAgentTools() + recorder = ActivityRecorder() + tools._activity_observer = recorder + await tools.send_event("internal", "thought") + assert recorder.events == [] + await dispatch(tools) + recorder.assert_completed_tool(BandTool.SEND_EVENT) diff --git a/tests/agent/test_startup_race_condition.py b/tests/agent/test_startup_race_condition.py index 36e01ff91..a493ea408 100644 --- a/tests/agent/test_startup_race_condition.py +++ b/tests/agent/test_startup_race_condition.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest @@ -14,7 +14,8 @@ ) from band.core.simple_adapter import SimpleAdapter from band.platform.event import MessageEvent -from band.runtime.context import ConversationContext +from band.runtime.execution import ExecutionContext +from band.runtime.sessionconfig import SessionConfig from band.testing.platform import platform_connection_stub @@ -26,7 +27,9 @@ class TestStartupRaceCondition: """ @pytest.mark.asyncio - async def test_adapter_on_started_before_first_message(self): + async def test_adapter_on_started_before_first_message( + self, mock_rest_client: MagicMock + ) -> None: """System prompt must be set before any message processing.""" # Track the order of calls @@ -93,20 +96,13 @@ async def start( self._on_execute = on_execute # Create a proper MessageEvent that passes through preprocessor - mock_ctx = MagicMock() - mock_ctx.is_llm_initialized = False - mock_ctx.mark_llm_initialized = MagicMock() - mock_ctx.get_context = AsyncMock( - return_value=ConversationContext( - room_id="room-123", - messages=[], - participants=[], - hydrated_at=datetime.now(UTC), - ) + mock_ctx = ExecutionContext( + room_id="room-123", + link=MagicMock(rest=mock_rest_client), + on_execute=on_execute, + agent_id="agent-123", + config=SessionConfig(enable_context_hydration=False), ) - mock_ctx.participants = [] - mock_ctx.participants_changed = MagicMock(return_value=False) - mock_ctx.room_id = "room-123" # Create a proper MessageEvent (not a MagicMock) message_event = MessageEvent( @@ -139,8 +135,4 @@ async def run_forever(self, *, install_signal_handlers: bool = True): # This will fail if on_message is called before on_started await agent.start() - # Verify on_started was called first - assert len(call_order) >= 1, "No calls were made" - assert call_order[0] == "on_started", ( - f"Expected on_started to be called first, but got: {call_order}" - ) + assert call_order == ["on_started", "on_message"] diff --git a/tests/framework_conformance/test_crewai_job_coverage.py b/tests/framework_conformance/test_crewai_job_coverage.py index 89f5314fc..5c98db779 100644 --- a/tests/framework_conformance/test_crewai_job_coverage.py +++ b/tests/framework_conformance/test_crewai_job_coverage.py @@ -75,6 +75,7 @@ def test_detection_finds_the_tests_that_only_the_crewai_venv_can_run(self) -> No Path("tests/integrations/test_crewai_flow_real_sdk.py"), Path("tests/integrations/test_crewai_real_tools.py"), Path("tests/integrations/test_crewai_completion.py"), + Path("tests/integrations/crewai/activity/test_execution.py"), Path("tests/test_capability_gating_e2e.py"), Path("tests/framework_conformance/test_files_image_passthrough_matrix.py"), } diff --git a/tests/framework_conformance/test_parlant_job_coverage.py b/tests/framework_conformance/test_parlant_job_coverage.py index b0bf342e3..c58e0e2e8 100644 --- a/tests/framework_conformance/test_parlant_job_coverage.py +++ b/tests/framework_conformance/test_parlant_job_coverage.py @@ -66,6 +66,7 @@ def test_detection_finds_the_tests_that_only_the_parlant_venv_can_run(self) -> N Path("tests/integrations/parlant/test_customtools.py"), Path("tests/integrations/parlant/test_filetools.py"), Path("tests/integrations/parlant/test_guard.py"), + Path("tests/integrations/parlant/activity/test_execution.py"), Path("tests/integrations/parlant/test_preambletask.py"), Path("tests/integrations/parlant/test_selection.py"), Path("tests/integrations/parlant/test_server.py"), diff --git a/tests/integrations/crewai/__init__.py b/tests/integrations/crewai/__init__.py new file mode 100644 index 000000000..9d48db4f9 --- /dev/null +++ b/tests/integrations/crewai/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/integrations/crewai/activity/__init__.py b/tests/integrations/crewai/activity/__init__.py new file mode 100644 index 000000000..9d48db4f9 --- /dev/null +++ b/tests/integrations/crewai/activity/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/integrations/crewai/activity/kit.py b/tests/integrations/crewai/activity/kit.py new file mode 100644 index 000000000..77e0cfd42 --- /dev/null +++ b/tests/integrations/crewai/activity/kit.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class LookupInput(BaseModel): + """Look up a private value.""" + + note: str diff --git a/tests/integrations/crewai/activity/test_execution.py b/tests/integrations/crewai/activity/test_execution.py new file mode 100644 index 000000000..6f532ac4c --- /dev/null +++ b/tests/integrations/crewai/activity/test_execution.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import json + +import pytest + +from band.integrations.crewai.tools import ( + CrewAIToolContext, + NoopReporter, + build_band_crewai_tools, +) +from band.testing import FakeAgentTools +from band.testing.activity import ActivityRecorder +from tests.integrations.crewai.activity.kit import LookupInput + +pytest.importorskip("crewai") + + +class TestCrewAIActivity: + @pytest.mark.parametrize("failed", [False, True]) + def test_real_base_tool_reports_without_narration_and_ends_on_error( + self, failed: bool + ) -> None: + recorder = ActivityRecorder() + tools = FakeAgentTools() + tools._activity_observer = recorder + + async def lookup(args: LookupInput) -> str: + assert recorder.events == [{"kind": "tool_call", "name": "lookup"}] + assert args.note == "private-argument" + if failed: + raise ValueError("private-error") + return "private-result" + + built = build_band_crewai_tools( + get_context=lambda: CrewAIToolContext(room_id="room", tools=tools), + reporter=NoopReporter(), + custom_tools=[(LookupInput, lookup)], + ) + lookup_tool = next(tool for tool in built if tool.name == "lookup") + result = json.loads(lookup_tool.run(note="private-argument")) + assert result["status"] == ("error" if failed else "success") + recorder.assert_completed_tool("lookup") + assert not tools.events_sent diff --git a/tests/integrations/parlant/activity/__init__.py b/tests/integrations/parlant/activity/__init__.py new file mode 100644 index 000000000..9d48db4f9 --- /dev/null +++ b/tests/integrations/parlant/activity/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/integrations/parlant/activity/kit.py b/tests/integrations/parlant/activity/kit.py new file mode 100644 index 000000000..77e0cfd42 --- /dev/null +++ b/tests/integrations/parlant/activity/kit.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class LookupInput(BaseModel): + """Look up a private value.""" + + note: str diff --git a/tests/integrations/parlant/activity/test_execution.py b/tests/integrations/parlant/activity/test_execution.py new file mode 100644 index 000000000..5bdafa3c1 --- /dev/null +++ b/tests/integrations/parlant/activity/test_execution.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import asyncio + +import pytest + +pytest.importorskip("parlant.sdk") + +from parlant.core.tools import ToolContext + +from band.integrations.parlant.customtools import build_custom_tool +from band.integrations.parlant.sessiontools import set_session_tools +from band.integrations.parlant.tools import create_parlant_tools +from band.testing import FakeAgentTools +from band.testing.activity import ActivityRecorder +from tests.integrations.parlant.activity.kit import LookupInput +from tests.integrations.parlant.helpers import ToolServer + + +class TestParlantActivity: + @pytest.mark.parametrize("failed", [False, True]) + async def test_real_server_reports_only_calling_session_and_always_ends( + self, plugin_server: ToolServer, failed: bool + ) -> None: + tools, other = FakeAgentTools(), FakeAgentTools() + recorder, quiet = ActivityRecorder(), ActivityRecorder() + tools._activity_observer = recorder + other._activity_observer = quiet + set_session_tools("calling", tools) + set_session_tools("other", other) + entered, release = asyncio.Event(), asyncio.Event() + + async def lookup(args: LookupInput) -> str: + assert args.note == "private-argument" + entered.set() + await release.wait() + if failed: + raise ValueError("private-error") + return "private-result" + + await plugin_server.enable( + create_parlant_tools(custom_tools=[(LookupInput, lookup)]) + ) + call = asyncio.create_task( + plugin_server.call( + "lookup", session_id="calling", arguments={"note": "private-argument"} + ) + ) + try: + await entered.wait() + assert recorder.events == [{"kind": "tool_call", "name": "lookup"}] + assert not quiet.events + finally: + release.set() + result = await call + assert result == ( + "Error running lookup: private-error" if failed else "private-result" + ) + recorder.assert_completed_tool("lookup") + assert not tools.events_sent + + async def test_custom_handler_cancellation_ends_activity(self) -> None: + tools = FakeAgentTools() + recorder = ActivityRecorder() + tools._activity_observer = recorder + set_session_tools("cancelled", tools) + entered = asyncio.Event() + + async def lookup(args: LookupInput) -> str: + entered.set() + await asyncio.Event().wait() + return "unreachable" + + entry = build_custom_tool((LookupInput, lookup)) + task = asyncio.create_task( + entry.function( + ToolContext( + agent_id="agent", session_id="cancelled", customer_id="user" + ), + note="private-argument", + ) + ) + await entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + recorder.assert_completed_tool("lookup") diff --git a/tests/runtime/activityownership/test_detail.py b/tests/runtime/activityownership/test_detail.py index 129ac2234..3049f5978 100644 --- a/tests/runtime/activityownership/test_detail.py +++ b/tests/runtime/activityownership/test_detail.py @@ -54,6 +54,8 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: PERMISSION_BODY, tool_body("Edit"), MEMORY_BODY, + # The reply is a Band tool call too; it ends back at reasoning. + REASONING_BODY, IDLE_BODY, ] diff --git a/tests/runtime/activityownership/test_housekeeping.py b/tests/runtime/activityownership/test_housekeeping.py new file mode 100644 index 000000000..96cbf1aaf --- /dev/null +++ b/tests/runtime/activityownership/test_housekeeping.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from collections.abc import Callable + +from band.platform.event import PlatformEvent +from band.runtime.execution import ExecutionContext +from band.runtime.tools.agent import AgentTools +from tests.conftest import make_participant_added_event +from tests.runtime.activityownership.kit import ActivityPlatform + + +class TestHousekeepingActivity: + async def test_band_tools_in_a_housekeeping_turn_never_start_reporting( + self, + execution: Callable[..., ExecutionContext], + activity_platform: ActivityPlatform, + ) -> None: + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + await AgentTools.from_context(ctx).get_participants() + + ctx = execution(handler) + await ctx._process_event(make_participant_added_event()) + await ctx._join_activity_completion() + assert activity_platform.activity_bodies == [] diff --git a/tests/runtime/activityownership/test_opencode_admission.py b/tests/runtime/activityownership/test_opencode_admission.py index 08d147f46..4d65bb763 100644 --- a/tests/runtime/activityownership/test_opencode_admission.py +++ b/tests/runtime/activityownership/test_opencode_admission.py @@ -72,12 +72,12 @@ async def test_a_message_during_an_approved_turns_reply_is_deferred_until_it_end assert activity_platform.marked("processed") == ["original"] assert activity_platform.marked("failed") == [] assert len(client.prompt_calls) == 1 - assert activity_platform.activity_reports == [KEEP_ALIVE] + assert activity_platform.indicator_changes == [KEEP_ALIVE] activity_platform.release.set() await turn_finished(first_turn) await ctx._join_activity_completion() - assert activity_platform.activity_reports == [KEEP_ALIVE, IDLE] + assert activity_platform.indicator_changes == [KEEP_ALIVE, IDLE] await ctx._reconcile() await ctx._join_activity_completion() @@ -88,7 +88,7 @@ async def test_a_message_during_an_approved_turns_reply_is_deferred_until_it_end FIRST_ANSWER, SECOND_ANSWER, ] - assert activity_platform.activity_reports == [ + assert activity_platform.indicator_changes == [ KEEP_ALIVE, IDLE, KEEP_ALIVE, diff --git a/tests/runtime/helpers.py b/tests/runtime/helpers.py index 5d6112fac..70d67c15a 100644 --- a/tests/runtime/helpers.py +++ b/tests/runtime/helpers.py @@ -184,6 +184,15 @@ def __init__(self, *, stopped: bool = False, page_size: int | None = None) -> No self.actionable_cursors: list[str | None] = [] self.activity_reports: list[ActivityReport] = [] + @property + def indicator_changes(self) -> list[ActivityReport]: + """The room's working-state reports with repeated refreshes collapsed.""" + return [ + report + for index, report in enumerate(self.activity_reports) + if index == 0 or report != self.activity_reports[index - 1] + ] + def add_message(self, message_id: str, *, content: str = "hello") -> None: self.messages.append( { diff --git a/tests/runtime/toolactivity/__init__.py b/tests/runtime/toolactivity/__init__.py new file mode 100644 index 000000000..9d48db4f9 --- /dev/null +++ b/tests/runtime/toolactivity/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/runtime/toolactivity/conftest.py b/tests/runtime/toolactivity/conftest.py new file mode 100644 index 000000000..dc6c3c340 --- /dev/null +++ b/tests/runtime/toolactivity/conftest.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from band.runtime.tools import AgentTools +from band.testing.activity import ActivityRecorder +from tests.runtime.helpers import ROOM_ID + + +@pytest.fixture +def recorder() -> ActivityRecorder: + return ActivityRecorder() + + +@pytest.fixture +def tools(mock_rest_client: MagicMock, recorder: ActivityRecorder) -> AgentTools: + """Real Band tools whose turn observer is the recorder.""" + tools = AgentTools(ROOM_ID, mock_rest_client) + tools._activity_observer = recorder + return tools diff --git a/tests/runtime/toolactivity/test_band_tools.py b/tests/runtime/toolactivity/test_band_tools.py new file mode 100644 index 000000000..8da889e0c --- /dev/null +++ b/tests/runtime/toolactivity/test_band_tools.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from band.runtime.tools import AgentTools +from band.runtime.tools.types import BandTool +from band.testing.activity import ActivityRecorder + + +class TestBandToolActivity: + async def test_a_band_tool_shows_its_name_until_it_ends( + self, tools: AgentTools, recorder: ActivityRecorder + ) -> None: + await tools.get_participants() + recorder.assert_completed_tool(BandTool.GET_PARTICIPANTS) + + async def test_a_failing_band_tool_still_returns_to_reasoning( + self, + tools: AgentTools, + recorder: ActivityRecorder, + mock_rest_client: MagicMock, + ) -> None: + participants = mock_rest_client.agent_api_participants + participants.list_agent_chat_participants.side_effect = RuntimeError("down") + with pytest.raises(RuntimeError): + await tools.get_participants() + recorder.assert_completed_tool(BandTool.GET_PARTICIPANTS) + + async def test_adapter_telemetry_is_quiet( + self, tools: AgentTools, recorder: ActivityRecorder + ) -> None: + await tools.send_event("private telemetry", "thought") + assert recorder.events == [] + + async def test_a_model_sent_event_shows_as_a_tool_call( + self, tools: AgentTools, recorder: ActivityRecorder + ) -> None: + await tools.execute_tool_call( + BandTool.SEND_EVENT, + {"content": "model telemetry", "message_type": "thought"}, + ) + recorder.assert_completed_tool(BandTool.SEND_EVENT) diff --git a/tests/runtime/toolactivity/test_cancelled_tool.py b/tests/runtime/toolactivity/test_cancelled_tool.py new file mode 100644 index 000000000..e3c63c122 --- /dev/null +++ b/tests/runtime/toolactivity/test_cancelled_tool.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import MagicMock + +from mcp import types +from mcp.server.fastmcp import FastMCP +from mcp.shared.memory import create_connected_server_and_client_session + +from band.integrations.mcp.engine import ( + EmbeddedResolver, + EngineSpec, + build_engine, + build_tool_registration, + extend_with_chat_id, +) +from band.runtime.tools import AgentTools +from band.runtime.tools.registry import TOOL_DEFINITIONS +from band.runtime.tools.types import BandTool +from band.testing.activity import ActivityRecorder +from tests.runtime.helpers import ROOM_ID + + +def participants_server(tools: AgentTools, request_ids: list[Any]) -> FastMCP: + """An MCP engine serving ``band_get_participants`` over ``tools``.""" + definition = TOOL_DEFINITIONS[BandTool.GET_PARTICIPANTS] + + def resolve(room_id: str) -> AgentTools: + request_ids.append(server.get_context().request_context.request_id) + return tools + + registration = build_tool_registration( + definition, + extend_with_chat_id(definition.input_model, None), + resolver=EmbeddedResolver(get_tools=resolve), + strip_chat_id=True, + ) + server = build_engine(EngineSpec(name="activity", tools=(registration,))) + return server + + +def cancel_request(request_id: Any) -> types.ClientNotification: + return types.ClientNotification( + types.CancelledNotification( + params=types.CancelledNotificationParams(requestId=request_id) + ) + ) + + +class TestCancelledToolActivity: + async def test_a_cancelled_mcp_tool_call_returns_to_reasoning( + self, + tools: AgentTools, + recorder: ActivityRecorder, + mock_rest_client: MagicMock, + ) -> None: + entered, cancelled = asyncio.Event(), asyncio.Event() + + async def held(*_args: Any, **_kwargs: Any) -> Any: + entered.set() + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + participants = mock_rest_client.agent_api_participants + participants.list_agent_chat_participants.side_effect = held + request_ids: list[Any] = [] + server = participants_server(tools, request_ids) + async with create_connected_server_and_client_session(server) as session: + request = asyncio.create_task( + session.call_tool(BandTool.GET_PARTICIPANTS, {"chat_id": ROOM_ID}) + ) + try: + await entered.wait() + await session.send_notification(cancel_request(request_ids[-1])) + await cancelled.wait() + finally: + request.cancel() + await asyncio.gather(request, return_exceptions=True) + + recorder.assert_completed_tool(BandTool.GET_PARTICIPANTS) diff --git a/tests/runtime/toolactivity/test_memory_tools.py b/tests/runtime/toolactivity/test_memory_tools.py new file mode 100644 index 000000000..c84e00e32 --- /dev/null +++ b/tests/runtime/toolactivity/test_memory_tools.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from band.runtime.tools import AgentTools +from band.runtime.tools.types import BandTool +from band.testing.activity import ActivityRecorder + +MEMORY_ID = "00000000-0000-0000-0000-000000000001" + +ToolCall = Callable[[AgentTools], Awaitable[Any]] + + +def store(tools: AgentTools) -> Awaitable[Any]: + return tools.store_memory( + content="remember this", + system="working", + type="semantic", + segment="user", + thought="useful later", + scope="organization", + ) + + +class TestMemoryToolActivity: + @pytest.mark.parametrize( + "call", + [ + store, + lambda tools: tools.supersede_memory(MEMORY_ID), + lambda tools: tools.archive_memory(MEMORY_ID), + ], + ids=["store", "supersede", "archive"], + ) + async def test_saving_memory_shows_memory_then_reasoning( + self, tools: AgentTools, recorder: ActivityRecorder, call: ToolCall + ) -> None: + await call(tools) + assert recorder.events == [{"kind": "memory"}, {"kind": "reasoning"}] + + @pytest.mark.parametrize( + ("name", "call"), + [ + (BandTool.GET_MEMORY, lambda tools: tools.get_memory(MEMORY_ID)), + (BandTool.LIST_MEMORIES, lambda tools: tools.list_memories()), + ], + ids=["get", "list"], + ) + async def test_reading_memory_shows_the_tool_call( + self, + tools: AgentTools, + recorder: ActivityRecorder, + name: BandTool, + call: ToolCall, + ) -> None: + await call(tools) + recorder.assert_completed_tool(name) diff --git a/tests/runtime/toolactivity/test_rebound_tools.py b/tests/runtime/toolactivity/test_rebound_tools.py new file mode 100644 index 000000000..069878b7c --- /dev/null +++ b/tests/runtime/toolactivity/test_rebound_tools.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import MagicMock + +from band.runtime.tools import AgentTools +from band.runtime.tools.types import BandTool +from band.testing.activity import ActivityRecorder + + +class TestReboundToolActivity: + async def test_a_tool_ends_on_the_turn_it_started_on( + self, + tools: AgentTools, + recorder: ActivityRecorder, + mock_rest_client: MagicMock, + ) -> None: + 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(tools.get_participants()) + await entered.wait() + next_turn = ActivityRecorder() + tools._activity_observer = next_turn + release.set() + await call + + recorder.assert_completed_tool(BandTool.GET_PARTICIPANTS) + assert next_turn.events == [] diff --git a/tests/runtime/toolactivity/test_undeclared_observer.py b/tests/runtime/toolactivity/test_undeclared_observer.py new file mode 100644 index 000000000..8b088e3d8 --- /dev/null +++ b/tests/runtime/toolactivity/test_undeclared_observer.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from band.integrations.claude_sdk.dedup_tools import DedupingAgentTools +from band.integrations.mcp.engine import dispatch_tool +from band.runtime.tools import AgentTools +from band.runtime.tools.registry import TOOL_DEFINITIONS +from band.runtime.tools.types import BandTool +from band.testing.activity import ActivityRecorder + +SEND_EVENT = TOOL_DEFINITIONS[BandTool.SEND_EVENT] +ARGUMENTS = {"content": "model", "message_type": "thought"} + + +class TestUndeclaredObserver: + async def test_a_proxy_reports_through_the_tools_it_wraps( + self, tools: AgentTools, recorder: ActivityRecorder + ) -> None: + await dispatch_tool(DedupingAgentTools(tools), SEND_EVENT, ARGUMENTS) + recorder.assert_completed_tool(BandTool.SEND_EVENT) + + async def test_tools_without_a_declared_observer_run_unreported(self) -> None: + tools = MagicMock(send_event=AsyncMock(return_value={"id": "evt-1"})) + await dispatch_tool(tools, SEND_EVENT, ARGUMENTS) + tools.send_event.assert_awaited_once() diff --git a/tests/runtime/tooltests/test_agent_tools_context_sync_back.py b/tests/runtime/tooltests/test_agent_tools_context_sync_back.py index fa6acd8ee..7d317c3b2 100644 --- a/tests/runtime/tooltests/test_agent_tools_context_sync_back.py +++ b/tests/runtime/tooltests/test_agent_tools_context_sync_back.py @@ -20,64 +20,6 @@ class TestAgentToolsContextSyncBack: On the next turn, from_context() would copy the stale list again. """ - @pytest.mark.asyncio - async def test_add_participant_syncs_back_to_ctx(self, mock_rest_client): - """add_participant() must call ctx.add_participant() with full dict.""" - mock_ctx = MagicMock() - mock_ctx.room_id = "room-456" - mock_ctx.link = MagicMock() - mock_ctx.link.rest = mock_rest_client - mock_ctx.participants = [] - mock_ctx.hub_room_id = None - mock_ctx.add_participant = MagicMock() - - # Override list_agent_chat_participants to return empty (avoid "already_in_room") - mock_rest_client.agent_api_participants.list_agent_chat_participants = ( - AsyncMock(return_value=MagicMock(data=[])) - ) - - tools = AgentTools.from_context(mock_ctx) - assert tools._ctx is mock_ctx - - await tools.add_participant("Agent Two") - - mock_ctx.add_participant.assert_called_once() - added = mock_ctx.add_participant.call_args.args[0] - assert added["id"] == "agent-2" - assert added["name"] == "Agent Two" - assert added["type"] == "Agent" - assert added["handle"] == "agent-two" - assert added["description"] == "Another agent" - - @pytest.mark.asyncio - async def test_remove_participant_syncs_back_to_ctx(self, mock_rest_client): - """remove_participant() must call ctx.remove_participant() with correct ID.""" - participant = { - "id": "user-1", - "name": "User One", - "type": "User", - "handle": "user-one", - } - - mock_ctx = MagicMock() - mock_ctx.room_id = "room-456" - mock_ctx.link = MagicMock() - mock_ctx.link.rest = mock_rest_client - mock_ctx.participants = [participant] - mock_ctx.hub_room_id = None - mock_ctx.remove_participant = MagicMock() - - # Return same participant from REST so snapshot matches - p_mock = make_participant_mock("user-1", "User One", "User", handle="user-one") - mock_rest_client.agent_api_participants.list_agent_chat_participants = ( - AsyncMock(return_value=MagicMock(data=[p_mock])) - ) - - tools = AgentTools.from_context(mock_ctx) - await tools.remove_participant("User One") - - mock_ctx.remove_participant.assert_called_once_with("user-1") - @pytest.mark.asyncio async def test_add_participant_persists_across_recreated_tools( self, mock_rest_client @@ -103,8 +45,17 @@ async def test_add_participant_persists_across_recreated_tools( tools1 = AgentTools.from_context(ctx) await tools1.add_participant("Agent Two") + added = ctx.participants[0] assert len(ctx.participants) == 1 - assert ctx.participants[0]["id"] == "agent-2" + assert { + key: added[key] for key in ("id", "name", "type", "handle", "description") + } == { + "id": "agent-2", + "name": "Agent Two", + "type": "Agent", + "handle": "agent-two", + "description": "Another agent", + } # Turn 2: recreate tools — participant must still be there tools2 = AgentTools.from_context(ctx) From 81740a2cdba4d97be6a3b964a57e4e57ad08cce5 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 18:44:28 +0300 Subject: [PATCH 30/33] refactor: Share the agent-tool method lookup between the turn decorators Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- src/band/runtime/turn.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/band/runtime/turn.py b/src/band/runtime/turn.py index 4a37ec860..98b5ba35b 100644 --- a/src/band/runtime/turn.py +++ b/src/band/runtime/turn.py @@ -3,7 +3,7 @@ from __future__ import annotations import functools -from collections.abc import AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator from contextlib import asynccontextmanager from typing import Any, TypeVar @@ -18,7 +18,7 @@ ) from band.runtime.tools.effects import turn_effect from band.runtime.tools.registry import TOOL_DEFINITIONS -from band.runtime.tools.types import BandTool, Surface, TurnEffect +from band.runtime.tools.types import BandTool, Surface, ToolDefinition, TurnEffect _ToolsClass = TypeVar("_ToolsClass", bound=type) @@ -34,6 +34,16 @@ def record_tool_result(turn: Turn, tool_name: str, result: Any) -> None: turn.record(effect) +def _agent_tool_methods( + cls: type, +) -> Iterator[tuple[ToolDefinition, Callable[..., Awaitable[Any]]]]: + """Each agent tool ``cls`` itself defines, with its method.""" + for definition in TOOL_DEFINITIONS.values(): + method = cls.__dict__.get(definition.method_name) + if definition.surface == Surface.AGENT and method is not None: + yield definition, method + + def _recording( tool_name: str, method: Callable[..., Awaitable[Any]] ) -> Callable[..., Awaitable[Any]]: @@ -52,10 +62,8 @@ def records_turn_effects(cls: _ToolsClass) -> _ToolsClass: Wrapping the methods themselves counts every call path: registry dispatch, a framework calling the method directly, and ``deliver_reply``. """ - for definition in TOOL_DEFINITIONS.values(): - method = cls.__dict__.get(definition.method_name) - if definition.surface == Surface.AGENT and method is not None: - setattr(cls, definition.method_name, _recording(definition.name, method)) + for definition, method in _agent_tool_methods(cls): + setattr(cls, definition.method_name, _recording(definition.name, method)) return cls @@ -113,12 +121,7 @@ async def observe(self: Any, *args: Any, **kwargs: Any) -> Any: def reports_tool_activity(cls: _ToolsClass) -> _ToolsClass: """Report every agent tool method except ``send_event`` as turn activity.""" - for definition in TOOL_DEFINITIONS.values(): - method = cls.__dict__.get(definition.method_name) - if ( - definition.surface == Surface.AGENT - and definition.name != BandTool.SEND_EVENT - and method is not None - ): + for definition, method in _agent_tool_methods(cls): + if definition.name != BandTool.SEND_EVENT: setattr(cls, definition.method_name, _observing(definition.name, method)) return cls From faea66201821fa84d61d13f9411edc8cfb29eaa9 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 19:22:38 +0300 Subject: [PATCH 31/33] fix: Make tool_activity the single owner of what a tool shows Only the outermost tool_activity reports, so a Band tool that calls other Band tools (add/remove participant) shows as itself throughout. Every model entry point (MCP, Pydantic AI, execute_tool_call, CrewAI, Parlant, Strands) now wraps its call in tool_activity; the decorator's send_event exclusion is the one predicate, and memory saves derive from the registry. Observer updates are best-effort. The tool-activity producers move to band.runtime.toolactivity. Tests drive the real boundaries: an in-memory MCP session, a pydantic_ai Agent with FunctionModel, a scripted Strands turn, the Slack tee, and the CrewAI/Parlant send_event tools, plus a multi-tool turn sequence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- .github/workflows/ci.yml | 2 + docs/turn-outcome.md | 11 +- src/band/adapters/strands.py | 4 +- src/band/integrations/crewai/catalog.py | 2 +- src/band/integrations/crewai/tools.py | 2 +- src/band/integrations/mcp/engine.py | 4 +- .../integrations/parlant/bandtools/chat.py | 2 +- src/band/integrations/parlant/customtools.py | 2 +- src/band/integrations/pydantic_ai/tools.py | 4 +- src/band/runtime/activity.py | 22 +--- src/band/runtime/toolactivity.py | 109 ++++++++++++++++++ src/band/runtime/tools/agent.py | 9 +- src/band/runtime/tools/registry.py | 12 +- src/band/runtime/turn.py | 89 +------------- src/band/testing/activity.py | 29 ++++- tests/adapters/activity/kit.py | 56 +++++++++ .../activity/test_failed_model_tool.py | 27 +++++ tests/adapters/activity/test_model_event.py | 53 ++++----- .../strands/test_model_event_activity.py | 42 +++++++ .../test_crewai_job_coverage.py | 1 + .../test_parlant_job_coverage.py | 1 + .../activity/kit.py => activitykit.py} | 2 + .../crewai/activity/test_execution.py | 16 ++- .../crewai/activity/test_send_event.py | 33 ++++++ tests/integrations/parlant/activity/kit.py | 9 -- .../parlant/activity/test_execution.py | 17 ++- .../parlant/activity/test_send_event.py | 31 +++++ tests/integrations/slack/test_activity.py | 34 ++++++ tests/runtime/activityownership/kit.py | 6 +- tests/runtime/activityownership/test_cap.py | 3 +- .../runtime/activityownership/test_control.py | 2 +- .../activityownership/test_delivery.py | 2 +- .../runtime/activityownership/test_detail.py | 6 +- .../activityownership/test_exclusion.py | 6 +- .../activityownership/test_negotiation.py | 2 +- .../activityownership/test_nonowner.py | 3 +- .../test_opencode_admission.py | 6 +- tests/runtime/helpers.py | 10 +- tests/runtime/toolactivity/conftest.py | 4 +- tests/runtime/toolactivity/kit.py | 54 +++++++++ tests/runtime/toolactivity/test_band_tools.py | 48 +++++++- .../toolactivity/test_cancelled_tool.py | 31 +---- .../toolactivity/test_failed_report.py | 56 +++++++++ .../runtime/toolactivity/test_memory_tools.py | 4 +- .../runtime/toolactivity/test_nested_tools.py | 55 +++++++++ .../toolactivity/test_rebound_tools.py | 2 +- .../toolactivity/test_turn_sequence.py | 55 +++++++++ 47 files changed, 743 insertions(+), 237 deletions(-) create mode 100644 src/band/runtime/toolactivity.py create mode 100644 tests/adapters/activity/kit.py create mode 100644 tests/adapters/activity/test_failed_model_tool.py create mode 100644 tests/adapters/strands/test_model_event_activity.py rename tests/integrations/{crewai/activity/kit.py => activitykit.py} (70%) create mode 100644 tests/integrations/crewai/activity/test_send_event.py delete mode 100644 tests/integrations/parlant/activity/kit.py create mode 100644 tests/integrations/parlant/activity/test_send_event.py create mode 100644 tests/integrations/slack/test_activity.py create mode 100644 tests/runtime/toolactivity/kit.py create mode 100644 tests/runtime/toolactivity/test_failed_report.py create mode 100644 tests/runtime/toolactivity/test_nested_tools.py create mode 100644 tests/runtime/toolactivity/test_turn_sequence.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3997dc7f..4c25dd963 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,6 +188,7 @@ jobs: tests/integrations/test_crewai_real_tools.py \ tests/integrations/test_crewai_completion.py \ tests/integrations/crewai/activity/test_execution.py \ + tests/integrations/crewai/activity/test_send_event.py \ tests/test_capability_gating_e2e.py \ tests/framework_conformance/test_files_image_passthrough_matrix.py \ tests/framework_conformance/ -k crewai @@ -327,6 +328,7 @@ jobs: tests/integrations/parlant/test_filetools.py \ tests/integrations/parlant/test_guard.py \ tests/integrations/parlant/activity/test_execution.py \ + tests/integrations/parlant/activity/test_send_event.py \ tests/integrations/parlant/test_preambletask.py \ tests/integrations/parlant/test_selection.py \ tests/integrations/parlant/test_server.py \ diff --git a/docs/turn-outcome.md b/docs/turn-outcome.md index c26443488..567fd8473 100644 --- a/docs/turn-outcome.md +++ b/docs/turn-outcome.md @@ -437,10 +437,13 @@ without cancelling provider work; actual completion still clears. Every Band tool call shows as a `tool_call` with its name, or `memory` while it saves a memory, then returns to reasoning when it ends, even on failure or -cancellation. Wrap any other tool call in `band.runtime.turn.tool_activity`. -`send_event` reports only where a model invoked it, since adapters also post -their own events through it. A tool's end lands on the turn it started on, even -if its tools are rebound to a newer turn meanwhile. +cancellation. `band.runtime.toolactivity` owns that mapping: wrap any other tool +call in its `tool_activity`. Only the outermost call reports, so a Band tool +that calls other Band tools shows as itself throughout. `send_event` reports +only where a model invoked it, since adapters also post their own events +through it. A tool's end lands on the turn it started on, even if its tools are +rebound to a newer turn meanwhile, and a failed report never changes the tool's +outcome. Detail compatibility is learned from the `X-API-Version` header the platform puts on every activity response. Reports stay boolean until a response in the diff --git a/src/band/adapters/strands.py b/src/band/adapters/strands.py index 809b6eba8..5ebe9e9cd 100644 --- a/src/band/adapters/strands.py +++ b/src/band/adapters/strands.py @@ -66,6 +66,7 @@ reject_conflicting_tool_names, ) from band.runtime.prompts import render_system_prompt, with_system_updates +from band.runtime.toolactivity import tool_activity from band.runtime.tools import ( ALL_TOOL_NAMES, ToolCallOutcome, @@ -354,7 +355,8 @@ async def _execute(self, arguments: dict[str, Any]) -> ToolCallOutcome: Callable[..., Awaitable[object]], getattr(self._tools, self._definition.method_name), ) - value = await method(**validated) + async with tool_activity(self._tools, self._definition.name): + value = await method(**validated) except Exception as error: logger.exception("Platform tool %s failed", self.tool_name) message = f"Error executing {self.tool_name}: {error}" diff --git a/src/band/integrations/crewai/catalog.py b/src/band/integrations/crewai/catalog.py index 1946bce0c..573ec6850 100644 --- a/src/band/integrations/crewai/catalog.py +++ b/src/band/integrations/crewai/catalog.py @@ -20,6 +20,7 @@ from band.core.protocols import AgentToolsProtocol from band.core.task_types import TaskAssignmentStatus, TaskLifecycleState, TaskListState from band.integrations.crewai.reporting import CrewAIToolReporter +from band.runtime.toolactivity import tool_activity from band.runtime.tools import ( BandTool, file_content_placeholder, @@ -29,7 +30,6 @@ serialize_tool_result, validate_tool_arguments, ) -from band.runtime.turn import tool_activity # --- Rendering a tool result as the text CrewAI hands the model --- diff --git a/src/band/integrations/crewai/tools.py b/src/band/integrations/crewai/tools.py index 7b480efd1..37387f503 100644 --- a/src/band/integrations/crewai/tools.py +++ b/src/band/integrations/crewai/tools.py @@ -53,6 +53,7 @@ execute_custom_tool, get_custom_tool_name, ) +from band.runtime.toolactivity import tool_activity from band.runtime.tools import ( EVENT_TOOL_NAMES, BandTool, @@ -61,7 +62,6 @@ get_tool_description, withheld_tool_names, ) -from band.runtime.turn import tool_activity logger = logging.getLogger(__name__) diff --git a/src/band/integrations/mcp/engine.py b/src/band/integrations/mcp/engine.py index a542c7b49..2bd34d752 100644 --- a/src/band/integrations/mcp/engine.py +++ b/src/band/integrations/mcp/engine.py @@ -49,6 +49,7 @@ execute_custom_tool, get_custom_tool_name, ) +from band.runtime.toolactivity import tool_activity from band.runtime.tools import ( CHAT_ID_FIELD_NAME, CHAT_ID_MAX_LENGTH, @@ -64,7 +65,6 @@ ) from band.runtime.tools.inputs.chat import require_visible_content from band.runtime.tools.inputs.identifiers import ResourceId -from band.runtime.turn import model_tool_activity logger = logging.getLogger(__name__) @@ -220,7 +220,7 @@ async def dispatch_tool( """ method = resolve_tool_method(tools, definition) try: - async with model_tool_activity(tools, definition.name): + async with tool_activity(tools, definition.name): return await method(**arguments) except (ValueError, BandToolError) as error: raise enrich_send_message_error(definition, tools, error) from error diff --git a/src/band/integrations/parlant/bandtools/chat.py b/src/band/integrations/parlant/bandtools/chat.py index 6a4a02035..311d87f94 100644 --- a/src/band/integrations/parlant/bandtools/chat.py +++ b/src/band/integrations/parlant/bandtools/chat.py @@ -15,8 +15,8 @@ split_mentions, ) from band.integrations.parlant.sessiontools import require_session_tools +from band.runtime.toolactivity import tool_activity from band.runtime.tools import BandTool, ParticipantAddStatus, serialize_tool_result -from band.runtime.turn import tool_activity EVENT_MESSAGE_TYPES: tuple[str, ...] = get_args(EventMessageType) diff --git a/src/band/integrations/parlant/customtools.py b/src/band/integrations/parlant/customtools.py index 1b1b7142e..fc4095d67 100644 --- a/src/band/integrations/parlant/customtools.py +++ b/src/band/integrations/parlant/customtools.py @@ -16,8 +16,8 @@ require_session_tools, ) from band.runtime.custom_tools import CustomToolDef, execute_custom_tool +from band.runtime.toolactivity import tool_activity from band.runtime.tools import tool_result_text -from band.runtime.turn import tool_activity # Parlant delivers every argument as a string, which only lax validation # converts, so even a strict field is validated laxly. diff --git a/src/band/integrations/pydantic_ai/tools.py b/src/band/integrations/pydantic_ai/tools.py index 349c6d06c..0fa1ff068 100644 --- a/src/band/integrations/pydantic_ai/tools.py +++ b/src/band/integrations/pydantic_ai/tools.py @@ -25,6 +25,7 @@ from band.core.protocols import AgentToolsProtocol from band.core.tool_filter import sanitize_tool_schema from band.core.types import AdapterFeatures, MessageType +from band.runtime.toolactivity import tool_activity from band.runtime.tools import ( BandTool, ToolDefinition, @@ -37,7 +38,6 @@ turn_effect, validate_tool_arguments, ) -from band.runtime.turn import model_tool_activity logger = logging.getLogger(__name__) @@ -203,7 +203,7 @@ async def dispatch(ctx: RunContext[AgentToolsProtocol], **arguments: Any) -> Any # something to hand the model as a tool error it could act on. method = _resolve_method(ctx.deps, definition) try: - async with model_tool_activity(ctx.deps, definition.name): + async with tool_activity(ctx.deps, definition.name): result = await method(**kwargs) except Exception as error: # noqa: BLE001 -- tool calls may raise any exception type; must surface to the LLM as an error string, not crash the turn return await _dispatch_failed(ctx, definition, error, notify_room=True) diff --git a/src/band/runtime/activity.py b/src/band/runtime/activity.py index 808fddf17..bffd8e92d 100644 --- a/src/band/runtime/activity.py +++ b/src/band/runtime/activity.py @@ -1,5 +1,5 @@ """Names-only room activity detail, the per-turn state that holds it, and the -producers a turn reports it through.""" +observer a turn reports it through.""" from __future__ import annotations @@ -59,28 +59,14 @@ def apply( def activity_observer_of(tools: object) -> ActivityObserver | None: """The turn observer bound to ``tools``, if it declares one.""" - # A dynamic proxy (``__getattr__``, a mock) would conjure an observer it - # never had; only a declared attribute counts. + # Only a declared attribute counts: a dynamic proxy (``__getattr__``, a + # mock) would conjure an observer it never had. The second, dynamic read + # resolves a declared property such as a wrapper's pass-through. if getattr_static(tools, "_activity_observer", None) is None: return None return getattr(tools, "_activity_observer", None) -async def report_tool_activity(tools: object, name: str) -> None: - if observer := activity_observer_of(tools): - await observer.update(tool_call_activity(name)) - - -async def end_tool_activity(tools: object) -> None: - if observer := activity_observer_of(tools): - await observer.update(REASONING) - - -async def report_memory_activity(tools: object) -> None: - if observer := activity_observer_of(tools): - await observer.update(MEMORY) - - async def set_permission_wait(tools: object, waiting: bool) -> None: if observer := activity_observer_of(tools): await observer.set_manual_wait(waiting) diff --git a/src/band/runtime/toolactivity.py b/src/band/runtime/toolactivity.py new file mode 100644 index 000000000..734a9ccec --- /dev/null +++ b/src/band/runtime/toolactivity.py @@ -0,0 +1,109 @@ +"""What a tool call shows as its turn's activity, and the one place it is reported.""" + +from __future__ import annotations + +import functools +import logging +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from contextvars import ContextVar +from typing import Any, TypeVar + +from band_rest.types.agent_activity_detail import AgentActivityDetail + +from band.runtime.activity import ( + MEMORY, + REASONING, + ActivityObserver, + activity_observer_of, + tool_call_activity, +) +from band.runtime.tools.effects import turn_effect +from band.runtime.tools.registry import MEMORY_TOOL_NAMES, agent_tool_methods +from band.runtime.tools.types import BandTool + +logger = logging.getLogger(__name__) + +_ToolsClass = TypeVar("_ToolsClass", bound=type) + +MEMORY_SAVE_TOOLS = frozenset( + name for name in MEMORY_TOOL_NAMES if turn_effect(name).did_work +) + +# Set while a tool call is shown, so a Band tool another tool calls stays hidden. +_showing_tool: ContextVar[bool] = ContextVar("band_showing_tool", default=False) + + +def tool_detail(name: str) -> AgentActivityDetail: + """What the room sees while ``name`` runs.""" + return MEMORY if name in MEMORY_SAVE_TOOLS else tool_call_activity(name) + + +def method_reports_activity(name: str) -> bool: + """Whether a tools method reports itself; adapters post their own events + through ``send_event``, so only a model's call to it reports.""" + return name != BandTool.SEND_EVENT + + +async def _show(observer: ActivityObserver, detail: AgentActivityDetail) -> None: + """Activity is best-effort: a failed report never changes the tool's outcome.""" + try: + await observer.update(detail) + except Exception: + logger.warning("Could not report %s activity", detail.kind, exc_info=True) + + +@asynccontextmanager +async def tool_activity(tools: object, name: str) -> AsyncIterator[None]: + """Show ``name`` as the turn's activity until the tool ends. + + Only the outermost call reports, so a tool that calls other Band tools + shows as itself throughout. The observer is resolved once so the end lands + on the turn the tool started on, even if ``tools`` is rebound meanwhile. + """ + observer = activity_observer_of(tools) + if observer is None or _showing_tool.get(): + yield + return + token = _showing_tool.set(True) + try: + await _show(observer, tool_detail(name)) + yield + finally: + _showing_tool.reset(token) + await _show(observer, REASONING) + + +async def report_tool_activity(tools: object, name: str) -> None: + """Show ``name`` for a tool whose start and end arrive as separate events.""" + if observer := activity_observer_of(tools): + await _show(observer, tool_detail(name)) + + +async def end_tool_activity(tools: object) -> None: + if observer := activity_observer_of(tools): + await _show(observer, REASONING) + + +async def report_memory_activity(tools: object) -> None: + if observer := activity_observer_of(tools): + await _show(observer, MEMORY) + + +def _observing( + tool_name: str, method: Callable[..., Awaitable[Any]] +) -> Callable[..., Awaitable[Any]]: + @functools.wraps(method) + async def observe(self: Any, *args: Any, **kwargs: Any) -> Any: + async with tool_activity(self, tool_name): + return await method(self, *args, **kwargs) + + return observe + + +def reports_tool_activity(cls: _ToolsClass) -> _ToolsClass: + """Report each agent tool method that ``method_reports_activity`` selects.""" + for definition, method in agent_tool_methods(cls): + if method_reports_activity(definition.name): + setattr(cls, definition.method_name, _observing(definition.name, method)) + return cls diff --git a/src/band/runtime/tools/agent.py b/src/band/runtime/tools/agent.py index 74c3efe6c..2edfe1fde 100644 --- a/src/band/runtime/tools/agent.py +++ b/src/band/runtime/tools/agent.py @@ -63,6 +63,7 @@ from band.runtime.context_serialization import context_item_to_dict from band.runtime.cycle import TurnScope from band.runtime.participants import log_roster_call, participant_snapshot +from band.runtime.toolactivity import reports_tool_activity, tool_activity from band.runtime.tools.registry import ( TOOL_DEFINITIONS, TOOL_MODELS, @@ -74,11 +75,7 @@ serialize_tool_result, validate_tool_arguments, ) -from band.runtime.turn import ( - model_tool_activity, - records_turn_effects, - reports_tool_activity, -) +from band.runtime.turn import records_turn_effects if TYPE_CHECKING: from anthropic.types import ToolParam @@ -2047,7 +2044,7 @@ async def execute_tool_call_structured( try: method = getattr(self, definition.method_name) - async with model_tool_activity(self, tool_name): + async with tool_activity(self, tool_name): result = await method(**arguments) return ToolCallOutcome(value=serialize_tool_result(result), ok=True) except (BandToolError, RoomExecutionStoppedError): diff --git a/src/band/runtime/tools/registry.py b/src/band/runtime/tools/registry.py index 28dc3df16..726b82813 100644 --- a/src/band/runtime/tools/registry.py +++ b/src/band/runtime/tools/registry.py @@ -10,7 +10,7 @@ from __future__ import annotations import base64 -from collections.abc import Collection, Mapping +from collections.abc import Awaitable, Callable, Collection, Iterator, Mapping from typing import Any, Literal, TypedDict, cast from pydantic import BaseModel @@ -920,6 +920,16 @@ def decode_image_blocks(result: Mapping[str, Any]) -> list[tuple[bytes, str]]: return [decode_image_block(block) for block in result["content"]] +def agent_tool_methods( + cls: type, +) -> Iterator[tuple[ToolDefinition, Callable[..., Awaitable[Any]]]]: + """Each agent tool ``cls`` itself defines, with its method.""" + for definition in TOOL_DEFINITIONS.values(): + method = cls.__dict__.get(definition.method_name) + if definition.surface == Surface.AGENT and method is not None: + yield definition, method + + def selected_tool_definitions( features: AdapterFeatures, *, is_hub_room: bool = False ) -> list[ToolDefinition]: diff --git a/src/band/runtime/turn.py b/src/band/runtime/turn.py index 98b5ba35b..7159de7db 100644 --- a/src/band/runtime/turn.py +++ b/src/band/runtime/turn.py @@ -1,24 +1,15 @@ -"""Records each Band tool call's effect and activity on the turn that made it.""" +"""Records each Band tool call's effect on the turn that made it.""" from __future__ import annotations import functools -from collections.abc import AsyncIterator, Awaitable, Callable, Iterator -from contextlib import asynccontextmanager +from collections.abc import Awaitable, Callable from typing import Any, TypeVar -from band_rest.types.agent_activity_detail import AgentActivityDetail - from band.core.turn import Turn -from band.runtime.activity import ( - MEMORY, - REASONING, - activity_observer_of, - tool_call_activity, -) from band.runtime.tools.effects import turn_effect -from band.runtime.tools.registry import TOOL_DEFINITIONS -from band.runtime.tools.types import BandTool, Surface, ToolDefinition, TurnEffect +from band.runtime.tools.registry import agent_tool_methods +from band.runtime.tools.types import TurnEffect _ToolsClass = TypeVar("_ToolsClass", bound=type) @@ -34,16 +25,6 @@ def record_tool_result(turn: Turn, tool_name: str, result: Any) -> None: turn.record(effect) -def _agent_tool_methods( - cls: type, -) -> Iterator[tuple[ToolDefinition, Callable[..., Awaitable[Any]]]]: - """Each agent tool ``cls`` itself defines, with its method.""" - for definition in TOOL_DEFINITIONS.values(): - method = cls.__dict__.get(definition.method_name) - if definition.surface == Surface.AGENT and method is not None: - yield definition, method - - def _recording( tool_name: str, method: Callable[..., Awaitable[Any]] ) -> Callable[..., Awaitable[Any]]: @@ -62,66 +43,6 @@ def records_turn_effects(cls: _ToolsClass) -> _ToolsClass: Wrapping the methods themselves counts every call path: registry dispatch, a framework calling the method directly, and ``deliver_reply``. """ - for definition, method in _agent_tool_methods(cls): + for definition, method in agent_tool_methods(cls): setattr(cls, definition.method_name, _recording(definition.name, method)) return cls - - -MEMORY_SAVE_TOOLS = frozenset( - {BandTool.STORE_MEMORY, BandTool.SUPERSEDE_MEMORY, BandTool.ARCHIVE_MEMORY} -) - - -def _tool_detail(name: str) -> AgentActivityDetail: - return MEMORY if name in MEMORY_SAVE_TOOLS else tool_call_activity(name) - - -@asynccontextmanager -async def tool_activity(tools: object, name: str) -> AsyncIterator[None]: - """Show ``name`` as the turn's activity until the tool ends. - - The observer is resolved once so the end lands on the turn the tool - started on, even if ``tools`` is rebound to another turn meanwhile. - """ - observer = activity_observer_of(tools) - if observer is None: - yield - return - try: - await observer.update(_tool_detail(name)) - yield - finally: - await observer.update(REASONING) - - -@asynccontextmanager -async def model_tool_activity(tools: object, name: str) -> AsyncIterator[None]: - """Report a model's tool call that the tools method itself does not. - - ``send_event`` is also the adapters' own telemetry path, so it reports - only where a model invoked it. - """ - if name != BandTool.SEND_EVENT: - yield - return - async with tool_activity(tools, name): - yield - - -def _observing( - tool_name: str, method: Callable[..., Awaitable[Any]] -) -> Callable[..., Awaitable[Any]]: - @functools.wraps(method) - async def observe(self: Any, *args: Any, **kwargs: Any) -> Any: - async with tool_activity(self, tool_name): - return await method(self, *args, **kwargs) - - return observe - - -def reports_tool_activity(cls: _ToolsClass) -> _ToolsClass: - """Report every agent tool method except ``send_event`` as turn activity.""" - for definition, method in _agent_tool_methods(cls): - if definition.name != BandTool.SEND_EVENT: - setattr(cls, definition.method_name, _observing(definition.name, method)) - return cls diff --git a/src/band/testing/activity.py b/src/band/testing/activity.py index 2be8317c0..b48a93922 100644 --- a/src/band/testing/activity.py +++ b/src/band/testing/activity.py @@ -2,10 +2,27 @@ from __future__ import annotations -from typing import Any +from typing import Any, Protocol, TypeVar from band_rest.types.agent_activity_detail import AgentActivityDetail +from band.runtime.activity import ActivityObserver + + +class ObservableTools(Protocol): + _activity_observer: ActivityObserver | None + + +_Tools = TypeVar("_Tools", bound=ObservableTools) + +SHOWN_REASONING: dict[str, Any] = {"kind": "reasoning"} +SHOWN_MEMORY: dict[str, Any] = {"kind": "memory"} + + +def shown_tool(name: str) -> dict[str, Any]: + """A recorded ``tool_call`` observation for ``name``.""" + return {"kind": "tool_call", "name": name} + class ActivityRecorder: """An ``ActivityObserver`` that keeps every observation in order.""" @@ -13,6 +30,11 @@ class ActivityRecorder: def __init__(self) -> None: self.events: list[dict[str, Any]] = [] + def observing(self, tools: _Tools) -> _Tools: + """Bind this recorder as the turn observer of ``tools``.""" + tools._activity_observer = self + return tools + async def update(self, activity: AgentActivityDetail) -> None: self.events.append(activity.model_dump(exclude_none=True)) @@ -20,7 +42,4 @@ async def set_manual_wait(self, waiting: bool) -> None: self.events.append({"waiting": waiting}) def assert_completed_tool(self, name: str) -> None: - assert self.events == [ - {"kind": "tool_call", "name": name}, - {"kind": "reasoning"}, - ] + assert self.events == [shown_tool(name), SHOWN_REASONING] diff --git a/tests/adapters/activity/kit.py b/tests/adapters/activity/kit.py new file mode 100644 index 000000000..c45e3d549 --- /dev/null +++ b/tests/adapters/activity/kit.py @@ -0,0 +1,56 @@ +"""Model entry points that drive one Band tool call through a real framework boundary.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from mcp.shared.memory import create_connected_server_and_client_session +from pydantic_ai import Agent +from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart, ToolCallPart +from pydantic_ai.models.function import AgentInfo, FunctionModel + +from band.core.protocols import AgentToolsProtocol +from band.core.types import AdapterFeatures +from band.integrations.pydantic_ai.tools import build_band_pydantic_ai_tools +from band.runtime.tools import AgentTools +from band.runtime.tools.registry import TOOL_DEFINITIONS +from tests.runtime.helpers import ROOM_ID +from tests.runtime.toolactivity.kit import band_tool_server + +ModelCall = Callable[[AgentTools, str, dict[str, Any]], Awaitable[Any]] + + +async def call_over_mcp(tools: AgentTools, name: str, arguments: dict[str, Any]) -> Any: + server = band_tool_server(tools, TOOL_DEFINITIONS[name]) + async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool(name, {**arguments, "chat_id": ROOM_ID}) + return result.content + + +async def call_over_pydantic_ai( + tools: AgentTools, name: str, arguments: dict[str, Any] +) -> Any: + """Run a real ``Agent`` whose model calls ``name`` once, then answers.""" + + def model(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse: + if len(messages) == 1: + return ModelResponse(parts=[ToolCallPart(tool_name=name, args=arguments)]) + return ModelResponse(parts=[TextPart("done")]) + + agent = Agent( + FunctionModel(model), + deps_type=AgentToolsProtocol, + tools=build_band_pydantic_ai_tools(AdapterFeatures()), + ) + result = await agent.run("go", deps=tools) + return result.all_messages() + + +async def call_through_execute_tool_call( + tools: AgentTools, name: str, arguments: dict[str, Any] +) -> Any: + return await tools.execute_tool_call(name, arguments) + + +MODEL_CALLS = [call_over_mcp, call_over_pydantic_ai, call_through_execute_tool_call] diff --git a/tests/adapters/activity/test_failed_model_tool.py b/tests/adapters/activity/test_failed_model_tool.py new file mode 100644 index 000000000..a53f2a77e --- /dev/null +++ b/tests/adapters/activity/test_failed_model_tool.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +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 MODEL_CALLS, ModelCall +from tests.runtime.helpers import ROOM_ID + + +class TestFailedModelToolActivity: + @pytest.mark.parametrize("call", MODEL_CALLS) + async def test_a_failing_call_returns_the_room_to_reasoning( + self, mock_rest_client: MagicMock, call: ModelCall + ) -> None: + listing = mock_rest_client.agent_api_participants.list_agent_chat_participants + listing.side_effect = RuntimeError("room unavailable") + recorder = ActivityRecorder() + tools = recorder.observing(AgentTools(ROOM_ID, mock_rest_client)) + + answer = await call(tools, BandTool.GET_PARTICIPANTS, {}) + + assert "room unavailable" in str(answer) + recorder.assert_completed_tool(BandTool.GET_PARTICIPANTS) diff --git a/tests/adapters/activity/test_model_event.py b/tests/adapters/activity/test_model_event.py index 9a6058b1d..2690ef98a 100644 --- a/tests/adapters/activity/test_model_event.py +++ b/tests/adapters/activity/test_model_event.py @@ -1,43 +1,40 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import MagicMock import pytest -from pydantic_ai import RunContext -from pydantic_ai.models.test import TestModel -from pydantic_ai.usage import RunUsage -from band.integrations.mcp.engine import dispatch_tool -from band.integrations.pydantic_ai.tools import _dispatcher -from band.runtime.tools.registry import TOOL_DEFINITIONS +from band.runtime.tools import AgentTools from band.runtime.tools.types import BandTool from band.testing.activity import ActivityRecorder -from band.testing.fake_tools import FakeAgentTools +from tests.adapters.activity.kit import MODEL_CALLS, ModelCall +from tests.runtime.helpers import ROOM_ID -SEND_EVENT = TOOL_DEFINITIONS[BandTool.SEND_EVENT] -ARGUMENTS = {"content": "model", "message_type": "thought"} +EVENT = {"content": "model telemetry", "message_type": "thought"} -async def dispatch_over_mcp(tools: FakeAgentTools) -> None: - await dispatch_tool(tools, SEND_EVENT, ARGUMENTS) +class TestModelToolActivity: + """A model's tool call shows exactly once, whichever framework dispatched it.""" - -async def dispatch_over_pydantic_ai(tools: FakeAgentTools) -> None: - dispatch = _dispatcher(SEND_EVENT, SEND_EVENT.input_model) - await dispatch( - RunContext(deps=tools, model=TestModel(), usage=RunUsage()), **ARGUMENTS + @pytest.mark.parametrize("call", MODEL_CALLS) + @pytest.mark.parametrize( + ("name", "arguments"), + [(BandTool.SEND_EVENT, EVENT), (BandTool.GET_PARTICIPANTS, {})], + ids=["send_event", "get_participants"], ) - - -class TestModelEventActivity: - @pytest.mark.parametrize("dispatch", [dispatch_over_mcp, dispatch_over_pydantic_ai]) - async def test_a_model_event_reports_while_raw_telemetry_stays_quiet( - self, dispatch: Callable[[FakeAgentTools], Awaitable[None]] + async def test_the_call_shows_once_while_adapter_telemetry_stays_quiet( + self, + mock_rest_client: MagicMock, + call: ModelCall, + name: BandTool, + arguments: dict[str, Any], ) -> None: - tools = FakeAgentTools() recorder = ActivityRecorder() - tools._activity_observer = recorder - await tools.send_event("internal", "thought") + tools = recorder.observing(AgentTools(ROOM_ID, mock_rest_client)) + await tools.send_event("adapter telemetry", "thought") assert recorder.events == [] - await dispatch(tools) - recorder.assert_completed_tool(BandTool.SEND_EVENT) + + await call(tools, name, arguments) + + recorder.assert_completed_tool(name) diff --git a/tests/adapters/strands/test_model_event_activity.py b/tests/adapters/strands/test_model_event_activity.py new file mode 100644 index 000000000..27ecdf707 --- /dev/null +++ b/tests/adapters/strands/test_model_event_activity.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest + +pytest.importorskip("strands", reason="strands extra not installed") + +from band.adapters.strands import StrandsAdapter +from band.runtime.tools import BandTool +from band.testing import FakeAgentTools, TextTurn, ToolTurn +from band.testing.activity import SHOWN_REASONING, ActivityRecorder, shown_tool +from tests.adapters.strands.kit import _run_turn + + +class TestStrandsToolActivity: + async def test_model_tool_calls_show_in_order_while_adapter_telemetry_stays_quiet( + self, + scripted: Callable[..., Awaitable[StrandsAdapter]], + tools: FakeAgentTools, + ) -> None: + recorder = ActivityRecorder() + recorder.observing(tools) + adapter = await scripted( + ToolTurn( + BandTool.SEND_EVENT, {"content": "model", "message_type": "thought"} + ), + ToolTurn( + BandTool.SEND_MESSAGE, {"content": "done", "mentions": ["@tester"]} + ), + TextTurn("finished"), + ) + + await _run_turn(adapter, tools) + + assert recorder.events == [ + shown_tool(BandTool.SEND_EVENT), + SHOWN_REASONING, + shown_tool(BandTool.SEND_MESSAGE), + SHOWN_REASONING, + ] + assert "model" in [event["content"] for event in tools.events_sent] diff --git a/tests/framework_conformance/test_crewai_job_coverage.py b/tests/framework_conformance/test_crewai_job_coverage.py index 5c98db779..e54c57981 100644 --- a/tests/framework_conformance/test_crewai_job_coverage.py +++ b/tests/framework_conformance/test_crewai_job_coverage.py @@ -76,6 +76,7 @@ def test_detection_finds_the_tests_that_only_the_crewai_venv_can_run(self) -> No Path("tests/integrations/test_crewai_real_tools.py"), Path("tests/integrations/test_crewai_completion.py"), Path("tests/integrations/crewai/activity/test_execution.py"), + Path("tests/integrations/crewai/activity/test_send_event.py"), Path("tests/test_capability_gating_e2e.py"), Path("tests/framework_conformance/test_files_image_passthrough_matrix.py"), } diff --git a/tests/framework_conformance/test_parlant_job_coverage.py b/tests/framework_conformance/test_parlant_job_coverage.py index c58e0e2e8..b1aee87d3 100644 --- a/tests/framework_conformance/test_parlant_job_coverage.py +++ b/tests/framework_conformance/test_parlant_job_coverage.py @@ -67,6 +67,7 @@ def test_detection_finds_the_tests_that_only_the_parlant_venv_can_run(self) -> N Path("tests/integrations/parlant/test_filetools.py"), Path("tests/integrations/parlant/test_guard.py"), Path("tests/integrations/parlant/activity/test_execution.py"), + Path("tests/integrations/parlant/activity/test_send_event.py"), Path("tests/integrations/parlant/test_preambletask.py"), Path("tests/integrations/parlant/test_selection.py"), Path("tests/integrations/parlant/test_server.py"), diff --git a/tests/integrations/crewai/activity/kit.py b/tests/integrations/activitykit.py similarity index 70% rename from tests/integrations/crewai/activity/kit.py rename to tests/integrations/activitykit.py index 77e0cfd42..17e847712 100644 --- a/tests/integrations/crewai/activity/kit.py +++ b/tests/integrations/activitykit.py @@ -1,3 +1,5 @@ +"""A custom tool shared by the framework activity suites.""" + from __future__ import annotations from pydantic import BaseModel diff --git a/tests/integrations/crewai/activity/test_execution.py b/tests/integrations/crewai/activity/test_execution.py index 6f532ac4c..e6df594f0 100644 --- a/tests/integrations/crewai/activity/test_execution.py +++ b/tests/integrations/crewai/activity/test_execution.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from typing import Any import pytest @@ -10,8 +11,8 @@ build_band_crewai_tools, ) from band.testing import FakeAgentTools -from band.testing.activity import ActivityRecorder -from tests.integrations.crewai.activity.kit import LookupInput +from band.testing.activity import ActivityRecorder, shown_tool +from tests.integrations.activitykit import LookupInput pytest.importorskip("crewai") @@ -22,12 +23,13 @@ def test_real_base_tool_reports_without_narration_and_ends_on_error( self, failed: bool ) -> None: recorder = ActivityRecorder() - tools = FakeAgentTools() - tools._activity_observer = recorder + tools = recorder.observing(FakeAgentTools()) + shown_while_running: list[dict[str, Any]] = [] + received: list[str] = [] async def lookup(args: LookupInput) -> str: - assert recorder.events == [{"kind": "tool_call", "name": "lookup"}] - assert args.note == "private-argument" + shown_while_running.extend(recorder.events) + received.append(args.note) if failed: raise ValueError("private-error") return "private-result" @@ -40,5 +42,7 @@ async def lookup(args: LookupInput) -> str: lookup_tool = next(tool for tool in built if tool.name == "lookup") result = json.loads(lookup_tool.run(note="private-argument")) assert result["status"] == ("error" if failed else "success") + assert received == ["private-argument"] + assert shown_while_running == [shown_tool("lookup")] recorder.assert_completed_tool("lookup") assert not tools.events_sent diff --git a/tests/integrations/crewai/activity/test_send_event.py b/tests/integrations/crewai/activity/test_send_event.py new file mode 100644 index 000000000..963fcf121 --- /dev/null +++ b/tests/integrations/crewai/activity/test_send_event.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import json + +import pytest + +from band.integrations.crewai.tools import ( + CrewAIToolContext, + NoopReporter, + build_band_crewai_tools, +) +from band.runtime.tools import BandTool +from band.testing import FakeAgentTools +from band.testing.activity import ActivityRecorder + +pytest.importorskip("crewai") + + +class TestCrewAIModelEventActivity: + def test_a_model_sent_event_reports_once_and_is_sent_once(self) -> None: + recorder = ActivityRecorder() + tools = recorder.observing(FakeAgentTools()) + built = build_band_crewai_tools( + get_context=lambda: CrewAIToolContext(room_id="room", tools=tools), + reporter=NoopReporter(), + ) + send_event = next(tool for tool in built if tool.name == BandTool.SEND_EVENT) + + result = json.loads(send_event.run(content="model", message_type="thought")) + + assert result["status"] == "success" + recorder.assert_completed_tool(BandTool.SEND_EVENT) + assert [event["content"] for event in tools.events_sent] == ["model"] diff --git a/tests/integrations/parlant/activity/kit.py b/tests/integrations/parlant/activity/kit.py deleted file mode 100644 index 77e0cfd42..000000000 --- a/tests/integrations/parlant/activity/kit.py +++ /dev/null @@ -1,9 +0,0 @@ -from __future__ import annotations - -from pydantic import BaseModel - - -class LookupInput(BaseModel): - """Look up a private value.""" - - note: str diff --git a/tests/integrations/parlant/activity/test_execution.py b/tests/integrations/parlant/activity/test_execution.py index 5bdafa3c1..d307f52e8 100644 --- a/tests/integrations/parlant/activity/test_execution.py +++ b/tests/integrations/parlant/activity/test_execution.py @@ -12,8 +12,8 @@ from band.integrations.parlant.sessiontools import set_session_tools from band.integrations.parlant.tools import create_parlant_tools from band.testing import FakeAgentTools -from band.testing.activity import ActivityRecorder -from tests.integrations.parlant.activity.kit import LookupInput +from band.testing.activity import ActivityRecorder, shown_tool +from tests.integrations.activitykit import LookupInput from tests.integrations.parlant.helpers import ToolServer @@ -22,10 +22,9 @@ class TestParlantActivity: async def test_real_server_reports_only_calling_session_and_always_ends( self, plugin_server: ToolServer, failed: bool ) -> None: - tools, other = FakeAgentTools(), FakeAgentTools() recorder, quiet = ActivityRecorder(), ActivityRecorder() - tools._activity_observer = recorder - other._activity_observer = quiet + tools = recorder.observing(FakeAgentTools()) + other = quiet.observing(FakeAgentTools()) set_session_tools("calling", tools) set_session_tools("other", other) entered, release = asyncio.Event(), asyncio.Event() @@ -48,7 +47,7 @@ async def lookup(args: LookupInput) -> str: ) try: await entered.wait() - assert recorder.events == [{"kind": "tool_call", "name": "lookup"}] + assert recorder.events == [shown_tool("lookup")] assert not quiet.events finally: release.set() @@ -60,10 +59,10 @@ async def lookup(args: LookupInput) -> str: assert not tools.events_sent async def test_custom_handler_cancellation_ends_activity(self) -> None: - tools = FakeAgentTools() + # The plugin server finishes a handler whose client disconnected, so + # only a direct call can deliver a cancellation to it. recorder = ActivityRecorder() - tools._activity_observer = recorder - set_session_tools("cancelled", tools) + set_session_tools("cancelled", recorder.observing(FakeAgentTools())) entered = asyncio.Event() async def lookup(args: LookupInput) -> str: diff --git a/tests/integrations/parlant/activity/test_send_event.py b/tests/integrations/parlant/activity/test_send_event.py new file mode 100644 index 000000000..fd6a649f2 --- /dev/null +++ b/tests/integrations/parlant/activity/test_send_event.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import pytest + +pytest.importorskip("parlant.sdk") + +from band.integrations.parlant.sessiontools import set_session_tools +from band.integrations.parlant.tools import create_parlant_tools +from band.runtime.tools import BandTool +from band.testing import FakeAgentTools +from band.testing.activity import ActivityRecorder +from tests.integrations.parlant.helpers import ToolServer + + +class TestParlantModelEventActivity: + async def test_a_model_sent_event_reports_once_and_is_sent_once( + self, plugin_server: ToolServer + ) -> None: + recorder = ActivityRecorder() + tools = recorder.observing(FakeAgentTools()) + set_session_tools("calling", tools) + await plugin_server.enable(create_parlant_tools()) + + await plugin_server.call( + BandTool.SEND_EVENT, + session_id="calling", + arguments={"content": "model", "message_type": "thought"}, + ) + + recorder.assert_completed_tool(BandTool.SEND_EVENT) + assert [event["content"] for event in tools.events_sent] == ["model"] diff --git a/tests/integrations/slack/test_activity.py b/tests/integrations/slack/test_activity.py new file mode 100644 index 000000000..af6b552d1 --- /dev/null +++ b/tests/integrations/slack/test_activity.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from band.integrations.slack.adapter import SlackTeeingTools +from band.integrations.slack.types import SlackRoomBinding +from band.runtime.tools import AgentTools, BandTool +from band.testing.activity import SHOWN_REASONING, ActivityRecorder, shown_tool +from tests.runtime.helpers import ROOM_ID + + +class TestSlackTeeActivity: + async def test_band_tools_called_through_the_tee_report_on_the_wrapped_turn( + self, mock_rest_client: MagicMock + ) -> None: + recorder = ActivityRecorder() + band = recorder.observing(AgentTools(ROOM_ID, mock_rest_client)) + tee = SlackTeeingTools( + wrap=band, + slack=AsyncMock(), + binding=SlackRoomBinding(app_slug="dev", channel="C", thread_ts="1.0"), + ) + + await tee.execute_tool_call(BandTool.GET_PARTICIPANTS, {}) + await tee.execute_tool_call( + BandTool.SEND_EVENT, {"content": "model", "message_type": "thought"} + ) + + assert recorder.events == [ + shown_tool(BandTool.GET_PARTICIPANTS), + SHOWN_REASONING, + shown_tool(BandTool.SEND_EVENT), + SHOWN_REASONING, + ] diff --git a/tests/runtime/activityownership/kit.py b/tests/runtime/activityownership/kit.py index 3c5afc722..b42c47503 100644 --- a/tests/runtime/activityownership/kit.py +++ b/tests/runtime/activityownership/kit.py @@ -9,13 +9,13 @@ import httpx from band.platform.activity import API_VERSION_HEADER -from band.runtime.activity import ( +from band.runtime.activity import set_permission_wait +from band.runtime.sessionconfig import SessionConfig +from band.runtime.toolactivity import ( end_tool_activity, report_memory_activity, report_tool_activity, - set_permission_wait, ) -from band.runtime.sessionconfig import SessionConfig from tests.activitysupport import SUPPORTED_VERSION from tests.runtime.helpers import ALICE, LifecyclePlatform, is_message_post, page diff --git a/tests/runtime/activityownership/test_cap.py b/tests/runtime/activityownership/test_cap.py index 70e8b5c26..0b5d1a0ef 100644 --- a/tests/runtime/activityownership/test_cap.py +++ b/tests/runtime/activityownership/test_cap.py @@ -6,8 +6,9 @@ import pytest from band.platform.event import PlatformEvent -from band.runtime.activity import report_tool_activity, set_permission_wait +from band.runtime.activity import set_permission_wait from band.runtime.execution import ExecutionContext +from band.runtime.toolactivity import report_tool_activity from band.runtime.tools.agent import AgentTools from tests.activitysupport import IDLE_BODY, REASONING_BODY, tool_body from tests.conftest import make_message_event diff --git a/tests/runtime/activityownership/test_control.py b/tests/runtime/activityownership/test_control.py index d925bc5a4..8257b81bb 100644 --- a/tests/runtime/activityownership/test_control.py +++ b/tests/runtime/activityownership/test_control.py @@ -7,8 +7,8 @@ from band.core.turn_lifecycle import TurnTask, bind_turn_task from band.platform.event import PlatformEvent -from band.runtime.activity import report_memory_activity, report_tool_activity from band.runtime.execution import ExecutionContext +from band.runtime.toolactivity import report_memory_activity, report_tool_activity from band.runtime.tools.agent import AgentTools from tests.activitysupport import IDLE_BODY, REASONING_BODY, tool_body from tests.conftest import make_message_event diff --git a/tests/runtime/activityownership/test_delivery.py b/tests/runtime/activityownership/test_delivery.py index 1e7945693..fc1460a8d 100644 --- a/tests/runtime/activityownership/test_delivery.py +++ b/tests/runtime/activityownership/test_delivery.py @@ -6,8 +6,8 @@ import pytest from band.platform.event import PlatformEvent -from band.runtime.activity import report_tool_activity from band.runtime.execution import ExecutionContext +from band.runtime.toolactivity import report_tool_activity from band.runtime.tools.agent import AgentTools from tests.activitysupport import tool_body from tests.conftest import make_message_event diff --git a/tests/runtime/activityownership/test_detail.py b/tests/runtime/activityownership/test_detail.py index 3049f5978..f7cee4acb 100644 --- a/tests/runtime/activityownership/test_detail.py +++ b/tests/runtime/activityownership/test_detail.py @@ -7,13 +7,13 @@ from band.core.turn_lifecycle import TurnTask, bind_turn_task from band.platform.event import PlatformEvent -from band.runtime.activity import ( +from band.runtime.activity import set_permission_wait +from band.runtime.execution import ExecutionContext +from band.runtime.toolactivity import ( end_tool_activity, report_memory_activity, report_tool_activity, - set_permission_wait, ) -from band.runtime.execution import ExecutionContext from band.runtime.tools.agent import AgentTools from tests.activitysupport import ( IDLE_BODY, diff --git a/tests/runtime/activityownership/test_exclusion.py b/tests/runtime/activityownership/test_exclusion.py index ffcd7ccf1..6e1c9c7f9 100644 --- a/tests/runtime/activityownership/test_exclusion.py +++ b/tests/runtime/activityownership/test_exclusion.py @@ -5,13 +5,13 @@ import pytest from band.platform.event import PlatformEvent -from band.runtime.activity import ( +from band.runtime.activity import set_permission_wait +from band.runtime.execution import ExecutionContext +from band.runtime.toolactivity import ( end_tool_activity, report_memory_activity, report_tool_activity, - set_permission_wait, ) -from band.runtime.execution import ExecutionContext from band.runtime.tools.agent import AgentTools from tests.conftest import make_message_event from tests.runtime.activityownership.kit import ActivityPlatform, past_a_keep_alive diff --git a/tests/runtime/activityownership/test_negotiation.py b/tests/runtime/activityownership/test_negotiation.py index 8194956a5..9958166ee 100644 --- a/tests/runtime/activityownership/test_negotiation.py +++ b/tests/runtime/activityownership/test_negotiation.py @@ -6,8 +6,8 @@ import pytest from band.platform.event import PlatformEvent -from band.runtime.activity import report_tool_activity from band.runtime.execution import ExecutionContext +from band.runtime.toolactivity import report_tool_activity from band.runtime.tools.agent import AgentTools from tests.activitysupport import ( BOOLEAN_BODY, diff --git a/tests/runtime/activityownership/test_nonowner.py b/tests/runtime/activityownership/test_nonowner.py index e13b7bc67..c12df5a5f 100644 --- a/tests/runtime/activityownership/test_nonowner.py +++ b/tests/runtime/activityownership/test_nonowner.py @@ -7,8 +7,9 @@ from band.core.turn_lifecycle import TurnTask, bind_turn_task from band.platform.event import PlatformEvent -from band.runtime.activity import report_tool_activity, set_permission_wait +from band.runtime.activity import set_permission_wait from band.runtime.execution import ExecutionContext +from band.runtime.toolactivity import report_tool_activity from band.runtime.tools.agent import AgentTools from tests.activitysupport import IDLE_BODY, REASONING_BODY, tool_body from tests.conftest import make_message_event diff --git a/tests/runtime/activityownership/test_opencode_admission.py b/tests/runtime/activityownership/test_opencode_admission.py index 4d65bb763..6cffc95cf 100644 --- a/tests/runtime/activityownership/test_opencode_admission.py +++ b/tests/runtime/activityownership/test_opencode_admission.py @@ -72,12 +72,12 @@ async def test_a_message_during_an_approved_turns_reply_is_deferred_until_it_end assert activity_platform.marked("processed") == ["original"] assert activity_platform.marked("failed") == [] assert len(client.prompt_calls) == 1 - assert activity_platform.indicator_changes == [KEEP_ALIVE] + assert activity_platform.reports_without_refreshes == [KEEP_ALIVE] activity_platform.release.set() await turn_finished(first_turn) await ctx._join_activity_completion() - assert activity_platform.indicator_changes == [KEEP_ALIVE, IDLE] + assert activity_platform.reports_without_refreshes == [KEEP_ALIVE, IDLE] await ctx._reconcile() await ctx._join_activity_completion() @@ -88,7 +88,7 @@ async def test_a_message_during_an_approved_turns_reply_is_deferred_until_it_end FIRST_ANSWER, SECOND_ANSWER, ] - assert activity_platform.indicator_changes == [ + assert activity_platform.reports_without_refreshes == [ KEEP_ALIVE, IDLE, KEEP_ALIVE, diff --git a/tests/runtime/helpers.py b/tests/runtime/helpers.py index 70d67c15a..a530b72e5 100644 --- a/tests/runtime/helpers.py +++ b/tests/runtime/helpers.py @@ -185,12 +185,14 @@ def __init__(self, *, stopped: bool = False, page_size: int | None = None) -> No self.activity_reports: list[ActivityReport] = [] @property - def indicator_changes(self) -> list[ActivityReport]: - """The room's working-state reports with repeated refreshes collapsed.""" + def reports_without_refreshes(self) -> list[ActivityReport]: + """The room's working-state reports with each run of keep-alive + refreshes collapsed; a repeated idle still shows twice.""" + reports = self.activity_reports return [ report - for index, report in enumerate(self.activity_reports) - if index == 0 or report != self.activity_reports[index - 1] + for index, report in enumerate(reports) + if not (report == KEEP_ALIVE and index and reports[index - 1] == KEEP_ALIVE) ] def add_message(self, message_id: str, *, content: str = "hello") -> None: diff --git a/tests/runtime/toolactivity/conftest.py b/tests/runtime/toolactivity/conftest.py index dc6c3c340..ea8321b65 100644 --- a/tests/runtime/toolactivity/conftest.py +++ b/tests/runtime/toolactivity/conftest.py @@ -17,6 +17,4 @@ def recorder() -> ActivityRecorder: @pytest.fixture def tools(mock_rest_client: MagicMock, recorder: ActivityRecorder) -> AgentTools: """Real Band tools whose turn observer is the recorder.""" - tools = AgentTools(ROOM_ID, mock_rest_client) - tools._activity_observer = recorder - return tools + return recorder.observing(AgentTools(ROOM_ID, mock_rest_client)) diff --git a/tests/runtime/toolactivity/kit.py b/tests/runtime/toolactivity/kit.py new file mode 100644 index 000000000..d8fb158de --- /dev/null +++ b/tests/runtime/toolactivity/kit.py @@ -0,0 +1,54 @@ +"""Real tool boundaries the activity suites drive Band tools through.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from band_rest.types.agent_activity_detail import AgentActivityDetail +from mcp.server.fastmcp import FastMCP + +from band.integrations.mcp.engine import ( + EmbeddedResolver, + EngineSpec, + build_engine, + build_tool_registration, + extend_with_chat_id, +) +from band.runtime.tools.types import ToolDefinition +from band.testing.activity import ActivityRecorder + + +def band_tool_server( + tools: Any, definition: ToolDefinition, *, request_ids: list[Any] | None = None +) -> FastMCP: + """An MCP engine serving ``definition`` over ``tools``, noting each request id.""" + + def resolve(room_id: str) -> Any: + if request_ids is not None: + request_ids.append(server.get_context().request_context.request_id) + return tools + + registration = build_tool_registration( + definition, + extend_with_chat_id(definition.input_model, None), + resolver=EmbeddedResolver(get_tools=resolve), + strip_chat_id=True, + ) + server = build_engine(EngineSpec(name="activity", tools=(registration,))) + return server + + +class UnreachableObserver(ActivityRecorder): + """Records each observation, then fails to deliver it.""" + + async def update(self, activity: AgentActivityDetail) -> None: + await super().update(activity) + raise RuntimeError("activity endpoint down") + + +class CancelledObserver(ActivityRecorder): + """A report that is cancelled before it lands.""" + + async def update(self, activity: AgentActivityDetail) -> None: + raise asyncio.CancelledError diff --git a/tests/runtime/toolactivity/test_band_tools.py b/tests/runtime/toolactivity/test_band_tools.py index 8da889e0c..637111096 100644 --- a/tests/runtime/toolactivity/test_band_tools.py +++ b/tests/runtime/toolactivity/test_band_tools.py @@ -1,20 +1,60 @@ from __future__ import annotations +from typing import Any from unittest.mock import MagicMock import pytest from band.runtime.tools import AgentTools -from band.runtime.tools.types import BandTool +from band.runtime.tools.registry import TOOL_DEFINITIONS +from band.runtime.tools.types import BandTool, Surface from band.testing.activity import ActivityRecorder +from tests.runtime.helpers import ROOM_ID + +USER_ONE = {"id": "user-1", "name": "User One", "type": "User", "handle": "user-one"} +EMPTY_PAGE = {"data": []} class TestBandToolActivity: + @pytest.mark.parametrize( + ("name", "arguments"), + [ + (BandTool.GET_PARTICIPANTS, {}), + (BandTool.SEND_MESSAGE, {"content": "hi", "mentions": ["user-one"]}), + (BandTool.LIST_CONTACTS, {}), + (BandTool.LIST_ROOM_FILES, {}), + (BandTool.LIST_TASKS, {}), + ], + ids=["participants", "chat", "contacts", "files", "tasks"], + ) async def test_a_band_tool_shows_its_name_until_it_ends( - self, tools: AgentTools, recorder: ActivityRecorder + self, + recorder: ActivityRecorder, + mock_rest_client: MagicMock, + name: BandTool, + arguments: dict[str, Any], ) -> None: - await tools.get_participants() - recorder.assert_completed_tool(BandTool.GET_PARTICIPANTS) + mock_rest_client.agent_api_contacts.list_agent_contacts.return_value = ( + EMPTY_PAGE + ) + mock_rest_client.agent_api_chat_tasks.list_chat_tasks.return_value = EMPTY_PAGE + tools = recorder.observing( + AgentTools(ROOM_ID, mock_rest_client, participants=[USER_ONE]) + ) + outcome = await tools.execute_tool_call_structured(name, arguments) + assert outcome.ok, outcome.error_message + recorder.assert_completed_tool(name) + + def test_every_agent_tool_is_defined_where_its_decorators_wrap_it(self) -> None: + # The class decorators wrap only methods AgentTools itself defines; one + # inherited from a base class would run unreported. + inherited = { + definition.name + for definition in TOOL_DEFINITIONS.values() + if definition.surface == Surface.AGENT + and definition.method_name not in vars(AgentTools) + } + assert inherited == set() async def test_a_failing_band_tool_still_returns_to_reasoning( self, diff --git a/tests/runtime/toolactivity/test_cancelled_tool.py b/tests/runtime/toolactivity/test_cancelled_tool.py index e3c63c122..a4f374cf3 100644 --- a/tests/runtime/toolactivity/test_cancelled_tool.py +++ b/tests/runtime/toolactivity/test_cancelled_tool.py @@ -5,39 +5,14 @@ from unittest.mock import MagicMock from mcp import types -from mcp.server.fastmcp import FastMCP from mcp.shared.memory import create_connected_server_and_client_session -from band.integrations.mcp.engine import ( - EmbeddedResolver, - EngineSpec, - build_engine, - build_tool_registration, - extend_with_chat_id, -) from band.runtime.tools import AgentTools from band.runtime.tools.registry import TOOL_DEFINITIONS from band.runtime.tools.types import BandTool from band.testing.activity import ActivityRecorder from tests.runtime.helpers import ROOM_ID - - -def participants_server(tools: AgentTools, request_ids: list[Any]) -> FastMCP: - """An MCP engine serving ``band_get_participants`` over ``tools``.""" - definition = TOOL_DEFINITIONS[BandTool.GET_PARTICIPANTS] - - def resolve(room_id: str) -> AgentTools: - request_ids.append(server.get_context().request_context.request_id) - return tools - - registration = build_tool_registration( - definition, - extend_with_chat_id(definition.input_model, None), - resolver=EmbeddedResolver(get_tools=resolve), - strip_chat_id=True, - ) - server = build_engine(EngineSpec(name="activity", tools=(registration,))) - return server +from tests.runtime.toolactivity.kit import band_tool_server def cancel_request(request_id: Any) -> types.ClientNotification: @@ -67,7 +42,9 @@ async def held(*_args: Any, **_kwargs: Any) -> Any: participants = mock_rest_client.agent_api_participants participants.list_agent_chat_participants.side_effect = held request_ids: list[Any] = [] - server = participants_server(tools, request_ids) + server = band_tool_server( + tools, TOOL_DEFINITIONS[BandTool.GET_PARTICIPANTS], request_ids=request_ids + ) async with create_connected_server_and_client_session(server) as session: request = asyncio.create_task( session.call_tool(BandTool.GET_PARTICIPANTS, {"chat_id": ROOM_ID}) diff --git a/tests/runtime/toolactivity/test_failed_report.py b/tests/runtime/toolactivity/test_failed_report.py new file mode 100644 index 000000000..0b1a6f3a3 --- /dev/null +++ b/tests/runtime/toolactivity/test_failed_report.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import asyncio +import logging +from unittest.mock import MagicMock + +import pytest + +from band.runtime.tools import AgentTools +from band.runtime.tools.types import BandTool +from tests.runtime.helpers import ROOM_ID +from tests.runtime.toolactivity.kit import CancelledObserver, UnreachableObserver + + +@pytest.fixture +def unreachable() -> UnreachableObserver: + return UnreachableObserver() + + +@pytest.fixture +def tools(mock_rest_client: MagicMock, unreachable: UnreachableObserver) -> AgentTools: + return unreachable.observing(AgentTools(ROOM_ID, mock_rest_client)) + + +class TestFailedActivityReport: + """Activity is best-effort: a failed report never changes the tool's outcome.""" + + async def test_the_tool_still_runs_and_returns_its_result( + self, + tools: AgentTools, + unreachable: UnreachableObserver, + caplog: pytest.LogCaptureFixture, + ) -> None: + with caplog.at_level(logging.WARNING, logger="band.runtime.toolactivity"): + participants = await tools.get_participants() + + assert participants + unreachable.assert_completed_tool(BandTool.GET_PARTICIPANTS) + assert [record.levelno for record in caplog.records] == [logging.WARNING] * 2 + + async def test_the_tool_keeps_its_own_failure( + self, tools: AgentTools, mock_rest_client: MagicMock + ) -> None: + listing = mock_rest_client.agent_api_participants.list_agent_chat_participants + listing.side_effect = ValueError("room gone") + with pytest.raises(ValueError, match="room gone"): + await tools.get_participants() + + async def test_a_cancelled_report_still_cancels_the_tool( + self, mock_rest_client: MagicMock + ) -> None: + tools = CancelledObserver().observing(AgentTools(ROOM_ID, mock_rest_client)) + with pytest.raises(asyncio.CancelledError): + await tools.get_participants() + listing = mock_rest_client.agent_api_participants.list_agent_chat_participants + listing.assert_not_awaited() diff --git a/tests/runtime/toolactivity/test_memory_tools.py b/tests/runtime/toolactivity/test_memory_tools.py index c84e00e32..b064f3202 100644 --- a/tests/runtime/toolactivity/test_memory_tools.py +++ b/tests/runtime/toolactivity/test_memory_tools.py @@ -7,7 +7,7 @@ from band.runtime.tools import AgentTools from band.runtime.tools.types import BandTool -from band.testing.activity import ActivityRecorder +from band.testing.activity import SHOWN_MEMORY, SHOWN_REASONING, ActivityRecorder MEMORY_ID = "00000000-0000-0000-0000-000000000001" @@ -39,7 +39,7 @@ async def test_saving_memory_shows_memory_then_reasoning( self, tools: AgentTools, recorder: ActivityRecorder, call: ToolCall ) -> None: await call(tools) - assert recorder.events == [{"kind": "memory"}, {"kind": "reasoning"}] + assert recorder.events == [SHOWN_MEMORY, SHOWN_REASONING] @pytest.mark.parametrize( ("name", "call"), diff --git a/tests/runtime/toolactivity/test_nested_tools.py b/tests/runtime/toolactivity/test_nested_tools.py new file mode 100644 index 000000000..bb5732056 --- /dev/null +++ b/tests/runtime/toolactivity/test_nested_tools.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from band.runtime.tools import AgentTools +from band.runtime.tools.types import BandTool +from band.testing.activity import SHOWN_REASONING, ActivityRecorder, shown_tool + + +class TestNestedToolActivity: + """A Band tool that calls other Band tools shows only itself.""" + + @pytest.mark.parametrize( + ("name", "call", "rest_method"), + [ + ( + BandTool.ADD_PARTICIPANT, + lambda tools: tools.add_participant("Agent Two"), + "add_agent_chat_participant", + ), + ( + BandTool.REMOVE_PARTICIPANT, + lambda tools: tools.remove_participant("User One"), + "remove_agent_chat_participant", + ), + ], + ids=["add", "remove"], + ) + async def test_the_change_runs_under_the_called_tool( + self, + tools: AgentTools, + recorder: ActivityRecorder, + mock_rest_client: MagicMock, + name: BandTool, + call: Callable[[AgentTools], Awaitable[Any]], + rest_method: str, + ) -> None: + participants = mock_rest_client.agent_api_participants + change = getattr(participants, rest_method) + shown_during_change: list[dict[str, Any]] = [] + answer = change.return_value + + async def record_change(*_args: Any, **_kwargs: Any) -> Any: + shown_during_change.extend(recorder.events) + return answer + + change.side_effect = record_change + await call(tools) + + assert shown_during_change == [shown_tool(name)] + assert recorder.events == [shown_tool(name), SHOWN_REASONING] diff --git a/tests/runtime/toolactivity/test_rebound_tools.py b/tests/runtime/toolactivity/test_rebound_tools.py index 069878b7c..107b47062 100644 --- a/tests/runtime/toolactivity/test_rebound_tools.py +++ b/tests/runtime/toolactivity/test_rebound_tools.py @@ -29,7 +29,7 @@ async def held(*_args: Any, **_kwargs: Any) -> Any: call = asyncio.create_task(tools.get_participants()) await entered.wait() next_turn = ActivityRecorder() - tools._activity_observer = next_turn + next_turn.observing(tools) release.set() await call diff --git a/tests/runtime/toolactivity/test_turn_sequence.py b/tests/runtime/toolactivity/test_turn_sequence.py new file mode 100644 index 000000000..f60914af7 --- /dev/null +++ b/tests/runtime/toolactivity/test_turn_sequence.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from band.runtime.tools import AgentTools +from band.runtime.tools.types import BandTool +from band.testing.activity import ( + SHOWN_MEMORY, + SHOWN_REASONING, + ActivityRecorder, + shown_tool, +) +from tests.runtime.helpers import ROOM_ID + +USER_ONE = {"id": "user-1", "name": "User One", "type": "User", "handle": "user-one"} +MEMORY = { + "content": "User One prefers short answers", + "system": "long_term", + "type": "semantic", + "segment": "user", + "thought": "shapes later replies", + "scope": "organization", +} + + +class TestTurnSequenceActivity: + async def test_a_turn_shows_each_tool_in_the_order_the_model_called_it( + self, recorder: ActivityRecorder, mock_rest_client: MagicMock + ) -> None: + memories = mock_rest_client.agent_api_memories + memories.create_agent_memory.return_value = SimpleNamespace( + data={"id": "memory-1"} + ) + tools = recorder.observing( + AgentTools(ROOM_ID, mock_rest_client, participants=[USER_ONE]) + ) + + calls = [ + (BandTool.LOOKUP_PEERS, {}), + (BandTool.SEND_MESSAGE, {"content": "Found one", "mentions": ["user-one"]}), + (BandTool.STORE_MEMORY, MEMORY), + ] + for name, arguments in calls: + outcome = await tools.execute_tool_call_structured(name, arguments) + assert outcome.ok, outcome.error_message + + assert recorder.events == [ + shown_tool(BandTool.LOOKUP_PEERS), + SHOWN_REASONING, + shown_tool(BandTool.SEND_MESSAGE), + SHOWN_REASONING, + SHOWN_MEMORY, + SHOWN_REASONING, + ] From 770875fbb2263a7a2d09b5b6894418a9bb19d448 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 19:51:18 +0300 Subject: [PATCH 32/33] fix: Show slack_send_message as turn activity and tighten activity tests The Slack tee's own slack_send_message now reports through tool_activity. The tools-class TypeVar is defined once beside agent_tool_methods, and the start/end producers share one resolve-and-show helper. Tests: failed and awaited memory saves, exact results and named failed reports, a proxy that would conjure an observer, an intent-named Strands turn helper, and a wire-level flow through a real ExecutionContext. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- src/band/integrations/slack/adapter.py | 12 ++-- src/band/runtime/toolactivity.py | 26 +++++---- src/band/runtime/tools/registry.py | 6 +- src/band/runtime/turn.py | 8 +-- tests/adapters/strands/kit.py | 2 +- .../strands/test_model_event_activity.py | 4 +- tests/adapters/strands/test_turn_failure.py | 12 ++-- .../strands/test_turn_productivity.py | 30 +++++----- tests/integrations/slack/test_activity.py | 14 ++++- .../activityownership/test_band_tool_wire.py | 55 +++++++++++++++++++ tests/runtime/toolactivity/kit.py | 18 ++++++ .../toolactivity/test_failed_report.py | 9 ++- .../runtime/toolactivity/test_memory_tools.py | 42 ++++++++++---- .../toolactivity/test_undeclared_observer.py | 14 +++-- 14 files changed, 186 insertions(+), 66 deletions(-) create mode 100644 tests/runtime/activityownership/test_band_tool_wire.py diff --git a/src/band/integrations/slack/adapter.py b/src/band/integrations/slack/adapter.py index cdc8c6412..6c85fd66e 100644 --- a/src/band/integrations/slack/adapter.py +++ b/src/band/integrations/slack/adapter.py @@ -61,6 +61,7 @@ dispose_adapter, dispose_framework, ) +from band.runtime.toolactivity import tool_activity from band.runtime.tools import AgentTools, ToolCallOutcome, TurnEffect from band.runtime.tools.schema import is_failed_tool_output @@ -215,11 +216,12 @@ async def slack_send_message(self, content: str) -> dict[str, Any]: failed (e.g. the channel was deleted, the bot was kicked). """ try: - await self._slack.chat_postMessage( - channel=self._binding.channel, - text=content, - thread_ts=self._binding.thread_ts, - ) + async with tool_activity(self, SLACK_SEND_MESSAGE_TOOL_NAME): + await self._slack.chat_postMessage( + channel=self._binding.channel, + text=content, + thread_ts=self._binding.thread_ts, + ) except Exception as exc: logger.exception( "slack_send_message failed (channel=%s thread_ts=%s)", diff --git a/src/band/runtime/toolactivity.py b/src/band/runtime/toolactivity.py index 734a9ccec..1d784469e 100644 --- a/src/band/runtime/toolactivity.py +++ b/src/band/runtime/toolactivity.py @@ -7,7 +7,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager from contextvars import ContextVar -from typing import Any, TypeVar +from typing import Any from band_rest.types.agent_activity_detail import AgentActivityDetail @@ -19,13 +19,15 @@ tool_call_activity, ) from band.runtime.tools.effects import turn_effect -from band.runtime.tools.registry import MEMORY_TOOL_NAMES, agent_tool_methods +from band.runtime.tools.registry import ( + MEMORY_TOOL_NAMES, + ToolsClass, + agent_tool_methods, +) from band.runtime.tools.types import BandTool logger = logging.getLogger(__name__) -_ToolsClass = TypeVar("_ToolsClass", bound=type) - MEMORY_SAVE_TOOLS = frozenset( name for name in MEMORY_TOOL_NAMES if turn_effect(name).did_work ) @@ -74,20 +76,22 @@ async def tool_activity(tools: object, name: str) -> AsyncIterator[None]: await _show(observer, REASONING) +async def _show_on(tools: object, detail: AgentActivityDetail) -> None: + if observer := activity_observer_of(tools): + await _show(observer, detail) + + async def report_tool_activity(tools: object, name: str) -> None: """Show ``name`` for a tool whose start and end arrive as separate events.""" - if observer := activity_observer_of(tools): - await _show(observer, tool_detail(name)) + await _show_on(tools, tool_detail(name)) async def end_tool_activity(tools: object) -> None: - if observer := activity_observer_of(tools): - await _show(observer, REASONING) + await _show_on(tools, REASONING) async def report_memory_activity(tools: object) -> None: - if observer := activity_observer_of(tools): - await _show(observer, MEMORY) + await _show_on(tools, MEMORY) def _observing( @@ -101,7 +105,7 @@ async def observe(self: Any, *args: Any, **kwargs: Any) -> Any: return observe -def reports_tool_activity(cls: _ToolsClass) -> _ToolsClass: +def reports_tool_activity(cls: ToolsClass) -> ToolsClass: """Report each agent tool method that ``method_reports_activity`` selects.""" for definition, method in agent_tool_methods(cls): if method_reports_activity(definition.name): diff --git a/src/band/runtime/tools/registry.py b/src/band/runtime/tools/registry.py index 726b82813..feafb3d14 100644 --- a/src/band/runtime/tools/registry.py +++ b/src/band/runtime/tools/registry.py @@ -11,7 +11,7 @@ import base64 from collections.abc import Awaitable, Callable, Collection, Iterator, Mapping -from typing import Any, Literal, TypedDict, cast +from typing import Any, Literal, TypedDict, TypeVar, cast from pydantic import BaseModel @@ -920,6 +920,10 @@ def decode_image_blocks(result: Mapping[str, Any]) -> list[tuple[bytes, str]]: return [decode_image_block(block) for block in result["content"]] +ToolsClass = TypeVar("ToolsClass", bound=type) +"""A tools class a decorator rewrites in place and returns.""" + + def agent_tool_methods( cls: type, ) -> Iterator[tuple[ToolDefinition, Callable[..., Awaitable[Any]]]]: diff --git a/src/band/runtime/turn.py b/src/band/runtime/turn.py index 7159de7db..649383cca 100644 --- a/src/band/runtime/turn.py +++ b/src/band/runtime/turn.py @@ -4,15 +4,13 @@ import functools from collections.abc import Awaitable, Callable -from typing import Any, TypeVar +from typing import Any from band.core.turn import Turn from band.runtime.tools.effects import turn_effect -from band.runtime.tools.registry import agent_tool_methods +from band.runtime.tools.registry import ToolsClass, agent_tool_methods from band.runtime.tools.types import TurnEffect -_ToolsClass = TypeVar("_ToolsClass", bound=type) - def record_tool_result(turn: Turn, tool_name: str, result: Any) -> None: """Record a successful Band tool call's effect on ``turn``. @@ -37,7 +35,7 @@ async def record_on_success(self: Any, *args: Any, **kwargs: Any) -> Any: return record_on_success -def records_turn_effects(cls: _ToolsClass) -> _ToolsClass: +def records_turn_effects(cls: ToolsClass) -> ToolsClass: """Record every agent tool method's effect on the instance's ``turn``. Wrapping the methods themselves counts every call path: registry dispatch, diff --git a/tests/adapters/strands/kit.py b/tests/adapters/strands/kit.py index 6a74a87e9..fae67b13d 100644 --- a/tests/adapters/strands/kit.py +++ b/tests/adapters/strands/kit.py @@ -72,7 +72,7 @@ async def _run_message( ) -async def _run_turn(adapter: StrandsAdapter, tools: FakeAgentTools) -> None: +async def run_judged_turn(adapter: StrandsAdapter, tools: FakeAgentTools) -> None: """Run one turn through ``on_event``, where the shared turn verdict lives.""" await adapter.on_event(turn_input(tools, msg=_make_msg(ROOM))) diff --git a/tests/adapters/strands/test_model_event_activity.py b/tests/adapters/strands/test_model_event_activity.py index 27ecdf707..a91cd71ae 100644 --- a/tests/adapters/strands/test_model_event_activity.py +++ b/tests/adapters/strands/test_model_event_activity.py @@ -10,7 +10,7 @@ from band.runtime.tools import BandTool from band.testing import FakeAgentTools, TextTurn, ToolTurn from band.testing.activity import SHOWN_REASONING, ActivityRecorder, shown_tool -from tests.adapters.strands.kit import _run_turn +from tests.adapters.strands.kit import run_judged_turn class TestStrandsToolActivity: @@ -31,7 +31,7 @@ async def test_model_tool_calls_show_in_order_while_adapter_telemetry_stays_quie TextTurn("finished"), ) - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) assert recorder.events == [ shown_tool(BandTool.SEND_EVENT), diff --git a/tests/adapters/strands/test_turn_failure.py b/tests/adapters/strands/test_turn_failure.py index c4377956d..11e662db4 100644 --- a/tests/adapters/strands/test_turn_failure.py +++ b/tests/adapters/strands/test_turn_failure.py @@ -49,8 +49,8 @@ SEND_TURN, _make_msg, _run_message, - _run_turn, _tool_results, + run_judged_turn, ) from tests.responsespeer import ResponsesPeer from tests.strandskit import outline @@ -102,7 +102,7 @@ async def test_bedrock_decline_preserves_followup_turn( ) await adapter.on_started("Bot", "A bot") first = FakeAgentTools(room_id=ROOM) - await _run_turn(adapter, first) + await run_judged_turn(adapter, first) second = FakeAgentTools(room_id=ROOM) await adapter.on_event( replace( @@ -146,7 +146,7 @@ async def test_responses_decline_preserves_followup_tool_output( ) await adapter.on_started("Bot", "A bot") first = FakeAgentTools(room_id=ROOM) - await _run_turn(adapter, first) + await run_judged_turn(adapter, first) second = FakeAgentTools(room_id=ROOM) await adapter.on_event( replace( @@ -195,7 +195,7 @@ async def test_responses_decline_ends_only_a_successful_turn( ) await adapter.on_started("Bot", "A bot") - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) assert len(peer.requests) == (2 if fails else 1) assert tools.turn.complete @@ -232,9 +232,9 @@ async def test_responses_tool_events_usage_and_failure( if fails: with pytest.raises(EventLoopException, match="provider down"): - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) else: - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) tools.assert_message_sent(content="hi", count=1) (call,) = events_of_type(tools, MessageType.TOOL_CALL) diff --git a/tests/adapters/strands/test_turn_productivity.py b/tests/adapters/strands/test_turn_productivity.py index fb49b5c69..9a3b9a48b 100644 --- a/tests/adapters/strands/test_turn_productivity.py +++ b/tests/adapters/strands/test_turn_productivity.py @@ -46,9 +46,9 @@ ROOM, SEND_TURN, _run_message, - _run_turn, _tool_results, failure_diagnostics, + run_judged_turn, ) from tests.framework_conformance.turnprobes import CUSTOM_TOOL_DECLARATIONS @@ -73,7 +73,7 @@ async def test_missing_reply_logs_reads_and_final_output_without_events( ) with pytest.raises(TurnResultAlreadyReported): - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) (diagnostic,) = failure_diagnostics(caplog) assert diagnostic["final_text"].strip() == final_text @@ -112,7 +112,7 @@ async def send_message( ) with pytest.raises(TurnResultAlreadyReported): - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) (diagnostic,) = failure_diagnostics(caplog) (call,) = diagnostic["tool_calls"] @@ -134,7 +134,7 @@ async def test_completed_turn_does_not_log_diagnostics( ) -> None: adapter = await scripted(SEND_TURN) with caplog.at_level(logging.WARNING): - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) assert failure_diagnostics(caplog) == [] @pytest.mark.asyncio @@ -192,7 +192,7 @@ async def inspect(args: InspectInput) -> dict[str, Any]: ) with pytest.raises(TurnResultAlreadyReported): - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) (diagnostic,) = failure_diagnostics(caplog) (call,) = diagnostic["tool_calls"] @@ -214,10 +214,10 @@ async def test_failure_diagnostics_only_include_the_failing_turn( ToolTurn("band_lookup_peers"), TextTurn("checked"), ) - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) with pytest.raises(TurnResultAlreadyReported): - await _run_turn(adapter, FakeAgentTools(room_id=ROOM)) + await run_judged_turn(adapter, FakeAgentTools(room_id=ROOM)) (diagnostic,) = failure_diagnostics(caplog) (call,) = diagnostic["tool_calls"] @@ -239,7 +239,7 @@ async def test_failure_diagnostics_keep_repeated_calls( emit=set(), ) with pytest.raises(TurnResultAlreadyReported): - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) (diagnostic,) = failure_diagnostics(caplog) first, second = diagnostic["tool_calls"] assert first["args"] == {"limit": 1} @@ -261,7 +261,7 @@ async def test_provider_failure_keeps_the_partial_tool_trace( emit=set(), ) with pytest.raises(EventLoopException, match="provider down"): - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) (diagnostic,) = failure_diagnostics(caplog) (call,) = diagnostic["tool_calls"] assert call["name"] == "band_list_tasks" @@ -281,7 +281,7 @@ async def send_message(self, content, mentions=None): adapter = await scripted(SEND_TURN) with pytest.raises(TurnResultAlreadyReported): - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) assert tools.messages_sent == [] assert failure_reports(tools) == [MISSING_REPLY_FAILURE] @@ -326,7 +326,7 @@ async def test_read_only_tool_alone_does_not_end_the_turn(self, tools, scripted) adapter = await scripted(ToolTurn("band_lookup_peers", {})) with pytest.raises(TurnResultAlreadyReported): - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) assert _tool_results(adapter) # the lookup did run and succeed assert tools.messages_sent == [] @@ -347,7 +347,7 @@ def native_finish(note: str) -> str: ToolTurn("native_finish", {"note": "go"}), additional_tools=[native_finish] ) - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) assert failure_reports(tools) == [] @@ -374,7 +374,7 @@ def native_finish(note: str) -> Any: ) with pytest.raises(TurnResultAlreadyReported): - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) assert failure_reports(tools) == [MISSING_REPLY_FAILURE] @@ -394,7 +394,7 @@ def native_peek(note: str) -> str: additional_tools=[native_peek], ) - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) tools.assert_message_sent(content="hi", count=1) assert tools.turn.complete @@ -412,7 +412,7 @@ def native_peek(note: str) -> str: ) with pytest.raises(TurnResultAlreadyReported): - await _run_turn(adapter, tools) + await run_judged_turn(adapter, tools) assert failure_reports(tools) == [MISSING_REPLY_FAILURE] diff --git a/tests/integrations/slack/test_activity.py b/tests/integrations/slack/test_activity.py index af6b552d1..51cc7a052 100644 --- a/tests/integrations/slack/test_activity.py +++ b/tests/integrations/slack/test_activity.py @@ -2,7 +2,10 @@ from unittest.mock import AsyncMock, MagicMock -from band.integrations.slack.adapter import SlackTeeingTools +from band.integrations.slack.adapter import ( + SLACK_SEND_MESSAGE_TOOL_NAME, + SlackTeeingTools, +) from band.integrations.slack.types import SlackRoomBinding from band.runtime.tools import AgentTools, BandTool from band.testing.activity import SHOWN_REASONING, ActivityRecorder, shown_tool @@ -10,14 +13,15 @@ class TestSlackTeeActivity: - async def test_band_tools_called_through_the_tee_report_on_the_wrapped_turn( + async def test_tools_called_through_the_tee_report_on_the_wrapped_turn( self, mock_rest_client: MagicMock ) -> None: recorder = ActivityRecorder() band = recorder.observing(AgentTools(ROOM_ID, mock_rest_client)) + slack = AsyncMock() tee = SlackTeeingTools( wrap=band, - slack=AsyncMock(), + slack=slack, binding=SlackRoomBinding(app_slug="dev", channel="C", thread_ts="1.0"), ) @@ -25,10 +29,14 @@ async def test_band_tools_called_through_the_tee_report_on_the_wrapped_turn( await tee.execute_tool_call( BandTool.SEND_EVENT, {"content": "model", "message_type": "thought"} ) + await tee.execute_tool_call(SLACK_SEND_MESSAGE_TOOL_NAME, {"content": "hi"}) assert recorder.events == [ shown_tool(BandTool.GET_PARTICIPANTS), SHOWN_REASONING, shown_tool(BandTool.SEND_EVENT), SHOWN_REASONING, + shown_tool(SLACK_SEND_MESSAGE_TOOL_NAME), + SHOWN_REASONING, ] + assert slack.chat_postMessage.await_args.kwargs["text"] == "hi" diff --git a/tests/runtime/activityownership/test_band_tool_wire.py b/tests/runtime/activityownership/test_band_tool_wire.py new file mode 100644 index 000000000..742506b24 --- /dev/null +++ b/tests/runtime/activityownership/test_band_tool_wire.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable + +import pytest + +from band.platform.event import PlatformEvent +from band.runtime.execution import ExecutionContext +from band.runtime.tools import BandTool +from band.runtime.tools.agent import AgentTools +from tests.activitysupport import IDLE_BODY, REASONING_BODY, tool_body +from tests.conftest import make_message_event +from tests.runtime.activityownership.kit import HeldReplyPlatform +from tests.runtime.helpers import IDLE, KEEP_ALIVE + +REPLY = "Found you" + + +@pytest.mark.looptime +class TestBandToolWireActivity: + """A model's Band tool call reaches the platform as the turn's detail.""" + + @pytest.fixture + async def activity_platform(self) -> AsyncIterator[HeldReplyPlatform]: + yield HeldReplyPlatform(held_reply=REPLY) + + async def test_a_model_reply_shows_on_the_wire_until_it_is_posted( + self, + execution: Callable[..., ExecutionContext], + activity_platform: HeldReplyPlatform, + ) -> None: + async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: + tools = AgentTools.from_context(ctx) + outcome = await tools.execute_tool_call_structured( + BandTool.SEND_MESSAGE, {"content": REPLY, "mentions": ["alice"]} + ) + assert outcome.ok, outcome.error_message + + ctx = execution(handler) + turn = asyncio.create_task(ctx._process_event(make_message_event())) + await activity_platform.reply_entered.wait() + shown = await activity_platform.body_at(1) + activity_platform.release.set() + await turn + + assert shown == tool_body(BandTool.SEND_MESSAGE) + assert activity_platform.room_messages == [REPLY] + assert activity_platform.activity_bodies == [ + REASONING_BODY, + tool_body(BandTool.SEND_MESSAGE), + REASONING_BODY, + IDLE_BODY, + ] + assert activity_platform.reports_without_refreshes == [KEEP_ALIVE, IDLE] diff --git a/tests/runtime/toolactivity/kit.py b/tests/runtime/toolactivity/kit.py index d8fb158de..ee384a3b2 100644 --- a/tests/runtime/toolactivity/kit.py +++ b/tests/runtime/toolactivity/kit.py @@ -52,3 +52,21 @@ class CancelledObserver(ActivityRecorder): async def update(self, activity: AgentActivityDetail) -> None: raise asyncio.CancelledError + + +class ConjuringProxy: + """Answers every attribute dynamically, as a mock or proxy would, and + conjures a recording observer it never declared.""" + + def __init__(self, conjured: ActivityRecorder) -> None: + self.conjured = conjured + self.events_sent: list[str] = [] + + def __getattr__(self, name: str) -> Any: + if name == "_activity_observer": + return self.conjured + raise AttributeError(name) + + async def send_event(self, content: str, message_type: str, **_: Any) -> str: + self.events_sent.append(content) + return "evt-1" diff --git a/tests/runtime/toolactivity/test_failed_report.py b/tests/runtime/toolactivity/test_failed_report.py index 0b1a6f3a3..e5b1e76e7 100644 --- a/tests/runtime/toolactivity/test_failed_report.py +++ b/tests/runtime/toolactivity/test_failed_report.py @@ -29,14 +29,19 @@ async def test_the_tool_still_runs_and_returns_its_result( self, tools: AgentTools, unreachable: UnreachableObserver, + mock_rest_client: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: + listing = mock_rest_client.agent_api_participants.list_agent_chat_participants with caplog.at_level(logging.WARNING, logger="band.runtime.toolactivity"): participants = await tools.get_participants() - assert participants + assert participants == listing.return_value.data unreachable.assert_completed_tool(BandTool.GET_PARTICIPANTS) - assert [record.levelno for record in caplog.records] == [logging.WARNING] * 2 + assert [record.getMessage() for record in caplog.records] == [ + "Could not report tool_call activity", + "Could not report reasoning activity", + ] async def test_the_tool_keeps_its_own_failure( self, tools: AgentTools, mock_rest_client: MagicMock diff --git a/tests/runtime/toolactivity/test_memory_tools.py b/tests/runtime/toolactivity/test_memory_tools.py index b064f3202..b9fba28ac 100644 --- a/tests/runtime/toolactivity/test_memory_tools.py +++ b/tests/runtime/toolactivity/test_memory_tools.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from typing import Any +from unittest.mock import MagicMock import pytest @@ -25,20 +26,41 @@ def store(tools: AgentTools) -> Awaitable[Any]: ) +SAVES = [ + (store, "create_agent_memory"), + (lambda tools: tools.supersede_memory(MEMORY_ID), "supersede_agent_memory"), + (lambda tools: tools.archive_memory(MEMORY_ID), "archive_agent_memory"), +] +SAVE_IDS = ["store", "supersede", "archive"] + + class TestMemoryToolActivity: - @pytest.mark.parametrize( - "call", - [ - store, - lambda tools: tools.supersede_memory(MEMORY_ID), - lambda tools: tools.archive_memory(MEMORY_ID), - ], - ids=["store", "supersede", "archive"], - ) + @pytest.mark.parametrize(("call", "rest_method"), SAVES, ids=SAVE_IDS) async def test_saving_memory_shows_memory_then_reasoning( - self, tools: AgentTools, recorder: ActivityRecorder, call: ToolCall + self, + tools: AgentTools, + recorder: ActivityRecorder, + mock_rest_client: MagicMock, + call: ToolCall, + rest_method: str, ) -> None: await call(tools) + getattr(mock_rest_client.agent_api_memories, rest_method).assert_awaited_once() + assert recorder.events == [SHOWN_MEMORY, SHOWN_REASONING] + + @pytest.mark.parametrize(("call", "rest_method"), SAVES, ids=SAVE_IDS) + async def test_a_failed_save_still_returns_to_reasoning( + self, + tools: AgentTools, + recorder: ActivityRecorder, + mock_rest_client: MagicMock, + call: ToolCall, + rest_method: str, + ) -> None: + save = getattr(mock_rest_client.agent_api_memories, rest_method) + save.side_effect = RuntimeError("memory store down") + with pytest.raises(RuntimeError, match="memory store down"): + await call(tools) assert recorder.events == [SHOWN_MEMORY, SHOWN_REASONING] @pytest.mark.parametrize( diff --git a/tests/runtime/toolactivity/test_undeclared_observer.py b/tests/runtime/toolactivity/test_undeclared_observer.py index 8b088e3d8..c1f623013 100644 --- a/tests/runtime/toolactivity/test_undeclared_observer.py +++ b/tests/runtime/toolactivity/test_undeclared_observer.py @@ -1,13 +1,12 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock - from band.integrations.claude_sdk.dedup_tools import DedupingAgentTools from band.integrations.mcp.engine import dispatch_tool from band.runtime.tools import AgentTools from band.runtime.tools.registry import TOOL_DEFINITIONS from band.runtime.tools.types import BandTool from band.testing.activity import ActivityRecorder +from tests.runtime.toolactivity.kit import ConjuringProxy SEND_EVENT = TOOL_DEFINITIONS[BandTool.SEND_EVENT] ARGUMENTS = {"content": "model", "message_type": "thought"} @@ -21,6 +20,11 @@ async def test_a_proxy_reports_through_the_tools_it_wraps( recorder.assert_completed_tool(BandTool.SEND_EVENT) async def test_tools_without_a_declared_observer_run_unreported(self) -> None: - tools = MagicMock(send_event=AsyncMock(return_value={"id": "evt-1"})) - await dispatch_tool(tools, SEND_EVENT, ARGUMENTS) - tools.send_event.assert_awaited_once() + conjured = ActivityRecorder() + proxy = ConjuringProxy(conjured) + + result = await dispatch_tool(proxy, SEND_EVENT, ARGUMENTS) + + assert result == "evt-1" + assert proxy.events_sent == ["model"] + assert conjured.events == [] From 429d478742227984487d90bfe432b1c0c4b90824 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 21 Oct 2026 20:04:12 +0300 Subject: [PATCH 33/33] style: Sort the activity kit imports Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFmR3J53UqbsYDMmTUd3Rb Signed-off-by: Alexander Zaikman --- tests/runtime/activityownership/kit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/runtime/activityownership/kit.py b/tests/runtime/activityownership/kit.py index cd5d83ecc..41455d737 100644 --- a/tests/runtime/activityownership/kit.py +++ b/tests/runtime/activityownership/kit.py @@ -14,12 +14,12 @@ from band.runtime.activity import set_permission_wait from band.runtime.execution import ExecutionContext from band.runtime.sessionconfig import SessionConfig -from band.runtime.tools.agent import AgentTools from band.runtime.toolactivity import ( end_tool_activity, report_memory_activity, report_tool_activity, ) +from band.runtime.tools.agent import AgentTools from tests.activitysupport import SUPPORTED_VERSION from tests.runtime.helpers import ALICE, LifecyclePlatform, is_message_post, page