From d1419b7db3c4388a746e577e9792921ae5e7d2d0 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 18 Oct 2026 10:08:37 +0300 Subject: [PATCH 01/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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]))], - ) - )