From 98569021834a37d260e6a8e284f4482429a1707f Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 11 Oct 2026 10:21:00 +0300 Subject: [PATCH 1/7] fix: preserve unaccepted work while keeping active-turn controls reachable Rebuild INT-1754 from main with one rejection signal, shared control handling, and the existing room scheduler. Preserve context and ACP sessions across proven non-acceptance. --- docs/acp.md | 15 +- docs/adapters/claude_sdk.md | 8 + docs/adapters/codex.md | 6 + docs/adapters/opencode.md | 7 + docs/turn-outcome.md | 25 +- docs/websocket-events.md | 36 +- src/band/adapters/claude_sdk.py | 68 ++- src/band/adapters/codex.py | 60 ++- src/band/adapters/opencode/adapter.py | 26 +- src/band/agent.py | 23 +- src/band/core/exceptions.py | 8 + src/band/core/protocols.py | 3 +- src/band/core/simple_adapter.py | 30 +- src/band/core/types.py | 15 + src/band/integrations/acp/client_adapter.py | 51 +- src/band/integrations/base.py | 7 +- src/band/platform/link.py | 22 +- src/band/platform/message_lifecycle.py | 33 +- src/band/preprocessing/default.py | 91 ++-- src/band/runtime/execution.py | 420 ++++++++++++----- src/band/runtime/oneshot.py | 116 ++++- src/band/runtime/platform_runtime.py | 3 + src/band/runtime/runtime.py | 4 + .../approvals/test_manual_approval.py | 19 +- tests/adapters/claude_sdk/helpers.py | 6 +- .../claude_sdk/lifecycle/test_on_message.py | 12 +- tests/adapters/dispatch.py | 37 ++ tests/adapters/opencode/helpers.py | 4 +- tests/adapters/opencode/test_approvals.py | 43 +- .../adapters/opencode/test_detached_turns.py | 52 +- tests/adapters/opencode/test_lifecycle.py | 63 ++- .../opencode/test_mcp_backend_replacement.py | 4 +- tests/adapters/opencode/test_setup.py | 31 +- tests/adapters/opencode/test_turns.py | 141 +++--- tests/adapters/test_codex_adapter.py | 429 +++++++++++------ tests/adapters/test_cursor_acp_adapter.py | 10 +- tests/core/test_simple_adapter.py | 16 +- tests/e2e/baseline/expected-cells.json | 27 ++ .../baseline/smoke/behavior/test_approvals.py | 68 ++- .../turnprobes/claudesdk.py | 5 +- .../turnprobes/opencode.py | 31 +- tests/integrations/acp/test_client_adapter.py | 2 - .../acp/test_client_adapter_behavior.py | 106 +++++ tests/integrations/codex/test_adapter_e2e.py | 11 +- tests/preprocessing/test_commitment.py | 118 +++++ tests/preprocessing/test_contacts_msg.py | 57 +-- tests/preprocessing/test_default.py | 7 +- tests/runtime/conftest.py | 1 + tests/runtime/helpers.py | 61 ++- tests/runtime/test_deferral.py | 446 ++++++++++++++++++ tests/runtime/test_oneshot.py | 1 + tests/test_agent.py | 8 +- 52 files changed, 2237 insertions(+), 656 deletions(-) create mode 100644 tests/adapters/dispatch.py create mode 100644 tests/preprocessing/test_commitment.py create mode 100644 tests/runtime/test_deferral.py diff --git a/docs/acp.md b/docs/acp.md index 5adad50e7..c224d3877 100644 --- a/docs/acp.md +++ b/docs/acp.md @@ -60,9 +60,18 @@ assert adapter.config.command == ("codex-acp",) MCP servers remain outside SDK registration control. - **Approved permissions are silent.** Only a denied request posts a synthetic `tool_call`/`tool_result` pair, and only when `Emit.TOOL_CALLS` is on. -- **Replay happens once**, only for a freshly minted session (a failed `session/load` - counts), under a nonce'd boundary marker so a replayed message cannot spoof it. History - stops strictly before the triggering message (`messages_before`). +- **Definitively rejected prompts stay pending.** The exact OMP JSON-RPC busy + response (`-32003` with `data.reason == "session_busy"`) raises + `InputNotAccepted` after one attempt. It preserves the room's runtime and + session without cancellation or a failure event; the shared runtime schedules + later attempts. Other errors retain normal failure cleanup. Session-scoped + external notifications cannot complete the rejected request: their successful + effects commit only after a prompt closes successfully. +- **Bootstrap commits after successful prompt completion.** A rejection keeps + room context owed. Fresh sessions also keep transcript replay owed; restored + sessions already hold their history and receive only Band context. Replay uses + a nonce'd boundary marker so history cannot spoof the live message, and stops + strictly before the triggering message (`messages_before`). ## Upgrading from SDK 4.x to 5.x diff --git a/docs/adapters/claude_sdk.md b/docs/adapters/claude_sdk.md index 0452e7717..eddd9528f 100644 --- a/docs/adapters/claude_sdk.md +++ b/docs/adapters/claude_sdk.md @@ -111,3 +111,11 @@ with pytest.raises(ValueError, match="wait_timeout_s must be less than"): A config is plain data, so a host can load it from YAML or JSON with `ClaudeSDKAdapterConfig.model_validate(data)`. + +An ordinary input arriving while accepted work owns the session remains +outstanding. Approval and status commands stay reachable through the shared +control hook under the existing sender and token checks. The active turn keeps +its original tools; handling a control never rebinds them to the new message. +Only a local rejection before `query()` qualifies as `InputNotAccepted`: +transport errors and timeouts do not establish that Claude rejected the input. +The shared room scheduler owns later attempts. diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md index 96110aac6..21faa6c20 100644 --- a/docs/adapters/codex.md +++ b/docs/adapters/codex.md @@ -49,3 +49,9 @@ For existing integrations, follow the [SDK 4.x to 5.x migration](../acp.md#upgra with `skills/extraRoots/set` before the room's thread starts. An older CLI rejects the request, and the room fails to start rather than running without the skills. Roots must be absolute paths; Codex accepts ones that don't exist. + +While an accepted turn awaits approval, newer ordinary room work remains +outstanding. The runtime can deliver approval and status controls ahead of that +pending work through Codex's existing authorization rules. The active turn keeps +its original tools; an ordinary input rejected before provider invocation is +retried by the shared room scheduler after that turn releases the session. diff --git a/docs/adapters/opencode.md b/docs/adapters/opencode.md index a37d18815..3b397597e 100644 --- a/docs/adapters/opencode.md +++ b/docs/adapters/opencode.md @@ -38,6 +38,13 @@ while the human decides), so the adapter judges that turn itself when OpenCode finishes it: a turn that ends without a reply then posts one missing-reply failure. A turn cancelled by cleanup or an interrupt posts nothing. +New ordinary work remains outstanding while the session is occupied. Approval, +status and question replies remain reachable through the shared control hook; +free text while a question is pending keeps its existing question-answer +meaning. Controls use their own delivery tools, while the active OpenCode turn +retains its original tools. The room scheduler retries rejected ordinary work +in order after the session becomes available. + `variant` is an opaque, provider- and model-specific name the running server owns: it may select reasoning effort or be a custom provider setting, so read the server's model catalog rather than assuming `"high"` exists. The model diff --git a/docs/turn-outcome.md b/docs/turn-outcome.md index 33ee1c27b..c15471546 100644 --- a/docs/turn-outcome.md +++ b/docs/turn-outcome.md @@ -15,7 +15,7 @@ A turn is **complete** when it: - declined on purpose (`band_no_reply`) - did real work (any Band tool whose effect is `act`, such as `band_add_participant`) -- was settled by the adapter itself (a control reply or a busy notice) +- was settled by the adapter itself (such as a control reply) - already reported a failure (`send_failure`) Anything else is a **missing reply**. Fetching state or narrating through @@ -115,6 +115,29 @@ async def main() -> None: asyncio.run(main()) ``` +## Input that was not accepted + +`InputNotAccepted` means this particular attempt provably started no provider +execution. A local occupancy check before sending or a validated provider busy +rejection can establish that; timeouts and transport errors cannot. The SDK does +not judge, fail or complete the rejected delivery, and does not spend its +execution-failure retry budget. It remains outstanding for the room scheduler. +One-shot invocation instead returns `OneShotStatus.NOT_ACCEPTED`; its caller +owns later invocation, with no acknowledgement or history drain. + +Ordinary pending work keeps its order. Adapter-owned approval, status and +question controls remain reachable behind it, using the existing authorization +rules and the control message's own tools. A consumed control completes only +itself. Already accepted detached work retains its original tools until its +actual end and follows the existing acknowledgement and judgment rules. + +The default preprocessor prepares bootstrap, participant and system context +without consuming it. A rejected attempt leaves that context owed; every other +outcome commits it conservatively, preserving updates that arrived during the +attempt. Custom preprocessors that consume context must defer that consumption +through `AgentInput.commit_context` to support this contract. This waiting is +process-local recovery, not an exactly-once guarantee across crashes. + ## Declining with `band_no_reply` `band_no_reply` is in every adapter's tool set, and `BASE_INSTRUCTIONS` tells diff --git a/docs/websocket-events.md b/docs/websocket-events.md index 60899af1d..696a3e866 100644 --- a/docs/websocket-events.md +++ b/docs/websocket-events.md @@ -7,13 +7,15 @@ and `src/band/platform/event.py`. What the code cannot say for itself: rule-free typed projections: `WirePayload.from_wire` has core validate and normalize the payload (alias sync, defaulting, coercion), then hydrates the model without re-validating. -- **Delivery-lifecycle decisions are core's too.** `OneShotInvoker` - (`src/band/runtime/oneshot.py`) is a thin caller-owns-the-loop wrapper over core's - `evaluate_delivery_event`, `evaluate_next_message`, `evaluate_drain_candidate` and - `evaluate_adapter_result`: event routing, drain-candidate classification (including - self-echo) and the ack decision are core's, not SDK logic. -- **`ExecutionContext` is a separate machine.** It calls none of those functions. It dedups - on `metadata.delivery_status` and calls core's `is_self_echo` directly. +- **Terminal delivery rules live in core.** `OneShotInvoker` + (`src/band/runtime/oneshot.py`) uses `evaluate_delivery_event`, + `evaluate_drain_candidate` and `evaluate_adapter_result` for event routing, + history drain classification and terminal acknowledgement. Python owns + ordinary-work ordering and adapter-control reachability. Definitively rejected + input returns before the terminal acknowledgement evaluator. +- **`ExecutionContext` owns room scheduling.** It reuses core's claims, retry + tracking, participant roster and self-echo rules, with delivery metadata for + deduplication. ## Room reconciliation @@ -41,3 +43,23 @@ retry cadence. A completed or skipped head that cannot advance ends the pass without spinning; retryable handler failures retain their normal attempt budget. These intervals bound scheduling, subject to active handlers, HTTP operations and recovery retries. + +## Pending work and control recovery + +A provider attempt that raises `InputNotAccepted` leaves its delivery +outstanding. The next scheduler pass retries the oldest work once at the base +reconciliation cadence. It releases local ownership and returns that attempt's +failure-budget charge; adapters do not wait or retry internally. Durable work +stays in platform rows, while synthetic messages remain in the local room queue. +No scopes or tools survive merely to retry an input. + +`/next` exposes only the oldest outstanding delivery. While that work is blocked, +the runtime also scans one chronological page of actionable `/messages` per +pass to find missed controls, retaining the cursor across ordinary traffic. +A changed pending head or the end of the scan starts a new scan. PLAY and +reconnect bring a pass forward without rewinding its cursor. + +Controls use the same duplicate checks and accepted processing gate before an +adapter handles them. A control consumed behind pending work acknowledges only +its own delivery; an ordinary candidate is left outstanding. Runtime recovery +never interprets approval syntax or drains model context for a control. diff --git a/src/band/adapters/claude_sdk.py b/src/band/adapters/claude_sdk.py index 75e710f3d..5149c4651 100644 --- a/src/band/adapters/claude_sdk.py +++ b/src/band/adapters/claude_sdk.py @@ -82,6 +82,7 @@ ) from band.core.adapterconfig import BaseAdapterConfig from band.core.defaultmodels import ANTHROPIC_MODEL +from band.core.exceptions import InputNotAccepted from band.core.protocols import ( FAILURE_CODE_TIMEOUT, GENERIC_PROVIDER_FAILURE_MESSAGE, @@ -761,6 +762,29 @@ def _room_mcp_servers(self, room_id: str) -> dict[str, McpServerConfig]: ) } + async def try_handle_control( + self, msg: PlatformMessage, tools: AgentToolsProtocol + ) -> bool: + if self.config.approvals is None: + return False + command = self._extract_command(msg.content) + if command is None: + return False + cmd, args = command + sender = {"id": msg.sender_id, "name": msg.sender_name or msg.sender_type} + if cmd in _APPROVAL_CMDS: + await self._handle_approval_command( + tools=tools, room_id=msg.room_id, command=cmd, args=args, sender=sender + ) + elif cmd == ClaudeSDKCommand.STATUS: + await self._handle_status_command( + tools=tools, room_id=msg.room_id, sender=sender + ) + else: + return False + tools.turn.settle() + return True + # --- Adapted from BandClaudeSDKAgent._handle_message --- async def on_message( self, @@ -787,52 +811,16 @@ async def on_message( "ClaudeSDKAdapter session manager not initialized — was on_started() called?" ) - # Approval flow: track notify target and intercept local commands + running_turn = self._turn_tasks.get(room_id) + if running_turn is not None and not running_turn.done(): + raise InputNotAccepted("The room already has an active Claude turn") if self.config.approvals is not None: self._room_last_sender[room_id] = { "id": msg.sender_id, "name": msg.sender_name or msg.sender_type, } - command = self._extract_command(msg.content) - if command is not None: - cmd, args = command - sender = self._room_last_sender[room_id] - if cmd in _APPROVAL_CMDS: - await self._handle_approval_command( - tools=tools, - room_id=room_id, - command=cmd, - args=args, - sender=sender, - ) - tools.turn.settle() - return - elif cmd == ClaudeSDKCommand.STATUS: - await self._handle_status_command( - tools=tools, - room_id=room_id, - sender=sender, - ) - tools.turn.settle() - return - - # A prior turn's background task (see _run_turn) may still be running - # this room's ClaudeSDKClient -- most commonly while it's parked on a - # manual approval. A second concurrent client.query() on the same - # session is unsafe, so refuse rather than race it (mirrors - # OpencodeAdapter's own "still processing" guard). - running_turn = self._turn_tasks.get(room_id) - if running_turn is not None and not running_turn.done(): - await tools.send_notice( - "Still processing the previous request in this room.", - mentions=[msg.sender_id] if msg.sender_id else None, - ) - tools.turn.settle() - return - # Bound only once this message runs a turn: a command or busy message - # must not take over the in-flight turn's tools, which its approval - # prompts and MCP tool calls still post through. + # The running turn's approvals and MCP calls keep their original tools. self._room_tools[room_id] = tools await self._bind_mcp_tools(room_id, tools) await self._mcp.ensure() diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 068eb5e2e..c65f288f8 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -41,6 +41,7 @@ deliver_notice, reraise_delivery_cause, ) +from band.core.exceptions import InputNotAccepted from band.core.protocols import ( FAILURE_CODE_TIMEOUT, GENERIC_PROVIDER_FAILURE_MESSAGE, @@ -169,9 +170,6 @@ class CodexCommand(StrEnum): APPROVAL_RESOLVED_TEMPLATE = "Approval `{token}` resolved as `{decision}`." APPROVAL_TIMED_OUT_TEMPLATE = "Approval `{token}` timed out. Applied `{decision}`." NO_APPROVALS_TO_RESOLVE_MESSAGE = "No pending approvals to resolve." -TURN_IN_PROGRESS_MESSAGE = ( - "Codex is still processing the previous request in this room." -) class CodexSubcommand(StrEnum): @@ -855,20 +853,17 @@ async def on_event(self, inp: AgentInput) -> None: self._raw_history_by_room[inp.room_id] = inp.history.raw await super().on_event(inp) - async def on_message( - self, - msg: PlatformMessage, - tools: AgentToolsProtocol, - history: CodexSessionState, - participants_msg: str | None, - contacts_msg: str | None, - *, - is_session_bootstrap: bool, - room_id: str, - ) -> None: + async def try_handle_control( + self, msg: PlatformMessage, tools: AgentToolsProtocol + ) -> bool: + command = self._extract_local_command(msg.content) + if command is None or ( + command[0] not in _APPROVAL_COMMANDS and command[0] != CodexCommand.STATUS + ): + return False + room_id = msg.room_id self._room_client(room_id) self._active_room.set(room_id) - command = self._extract_local_command(msg.content) if command is not None and command[0] in _APPROVAL_COMMANDS: try: handled = await self._handle_approval_command( @@ -882,15 +877,38 @@ async def on_message( reraise_delivery_cause(e) if handled: tools.turn.settle() - return + return True - running = self._turn_tasks.get(room_id) - if running is not None and not running.done(): - await tools.send_notice( - TURN_IN_PROGRESS_MESSAGE, mentions=self._sender_mention(msg) + if command is not None and command[0] == CodexCommand.STATUS: + await self._handle_local_command( + tools=tools, + msg=msg, + history=CodexSessionState(), + room_id=room_id, + command=command[0], + args=command[1], ) tools.turn.settle() - return + return True + return False + + async def on_message( + self, + msg: PlatformMessage, + tools: AgentToolsProtocol, + history: CodexSessionState, + participants_msg: str | None, + contacts_msg: str | None, + *, + is_session_bootstrap: bool, + room_id: str, + ) -> None: + self._room_client(room_id) + self._active_room.set(room_id) + running = self._turn_tasks.get(room_id) + if running is not None and not running.done(): + raise InputNotAccepted("The room already has an active Codex turn") + command = self._extract_local_command(msg.content) release: asyncio.Future[None] = asyncio.get_running_loop().create_future() self._turn_release[room_id] = release diff --git a/src/band/adapters/opencode/adapter.py b/src/band/adapters/opencode/adapter.py index 3e130d84a..8de9c2381 100644 --- a/src/band/adapters/opencode/adapter.py +++ b/src/band/adapters/opencode/adapter.py @@ -20,7 +20,7 @@ from band.adapters.opencode.config import OpencodeAdapterConfig from band.converters.opencode import OpencodeHistoryConverter from band.core.delivery import relay_reply -from band.core.exceptions import BandConnectionError +from band.core.exceptions import BandConnectionError, InputNotAccepted from band.core.protocols import ( FAILURE_CODE_TIMEOUT, GENERIC_PROVIDER_FAILURE_MESSAGE, @@ -415,6 +415,15 @@ def _log_startup_config(self, agent_name: str) -> None: len(self._custom_tools), ) + async def try_handle_control( + self, msg: PlatformMessage, tools: AgentToolsProtocol + ) -> bool: + room_state = await self._get_or_create_room_state(msg.room_id) + if not await room_state.approvals.try_handle_reply(msg.content, msg.sender_id): + return False + tools.turn.settle() + return True + async def on_message( self, msg: PlatformMessage, @@ -434,24 +443,13 @@ async def on_message( agent_id or self.agent_name ) - if await room_state.approvals.try_handle_reply(msg.content, msg.sender_id): - tools.turn.settle() - return - if room_state.turn and ( not room_state.turn.turn_future.done() or room_state.approvals.awaiting_human() ): - await tools.send_event( - "OpenCode is still processing the previous request in this room.", - "error", - ) - tools.turn.settle() - return + raise InputNotAccepted("The room already has an active OpenCode turn") - # Only a message that starts a turn rebinds the room's tools: the MCP - # backend resolves band tool calls through them, so a message consumed - # above must not take over the in-flight turn's tools. + # MCP calls keep the active turn's tools until that turn finishes. room_state.tools = tools await self._ensure_client_started() diff --git a/src/band/agent.py b/src/band/agent.py index 9b42d0a44..338b2889e 100644 --- a/src/band/agent.py +++ b/src/band/agent.py @@ -11,11 +11,13 @@ from types import TracebackType from typing import TYPE_CHECKING, Any, Self, cast +from band.core.exceptions import InputNotAccepted from band.core.protocols import FrameworkAdapter, Preprocessor from band.core.simple_adapter import SimpleAdapter from band.preprocessing.default import DefaultPreprocessor from band.runtime.platform_runtime import PlatformRuntime from band.runtime.startup import release_adapter, start_adapter +from band.runtime.tools import AgentTools from band.runtime.types import ( AgentConfig, ContactEventConfig, @@ -290,6 +292,7 @@ async def start(self) -> None: try: await self._runtime.start( on_execute=self._on_execute, + on_message_control=self._on_control, on_cleanup=self._adapter.on_cleanup, # A bare FrameworkAdapter predates on_interrupt and needn't # implement it -- degrade to a no-op signal like other @@ -460,4 +463,22 @@ async def _on_execute( if inp is None: return - await self._adapter.on_event(inp) + rejected = False + try: + await self._adapter.on_event(inp) + except InputNotAccepted: + rejected = True + raise + finally: + if not rejected and inp.commit_context is not None: + inp.commit_context() + + async def _on_control(self, ctx: ExecutionContext, event: PlatformEvent) -> bool: + """Handle controls with their delivery tools, preserving active ownership.""" + handler = getattr(self._adapter, "try_handle_control", None) + if handler is None: + return False + msg = DefaultPreprocessor.message(ctx, event, self._runtime.agent_id) + if msg is None: + return False + return await handler(msg, AgentTools.from_context(ctx)) diff --git a/src/band/core/exceptions.py b/src/band/core/exceptions.py index 5356ff06d..4d0d528a2 100644 --- a/src/band/core/exceptions.py +++ b/src/band/core/exceptions.py @@ -54,6 +54,14 @@ class BandToolError(BandError): """Tool execution failures. Actionable by adapter/LLM.""" +class InputNotAccepted(BandError): + """This attempt provably started no provider execution. + + Transport failures, timeouts and uncertain acceptance do not qualify. + The host retains the input without reporting a failed turn. + """ + + class ProviderRunError(BandError): """A provider or framework returned a failed run as data instead of raising. Actionable by the adapter: raise it inside the turn so the turn is reported diff --git a/src/band/core/protocols.py b/src/band/core/protocols.py index cd23ddfa3..1a101c79f 100644 --- a/src/band/core/protocols.py +++ b/src/band/core/protocols.py @@ -495,7 +495,8 @@ class Preprocessor(Protocol): Converts platform events to AgentInput. Most users use DefaultPreprocessor. - Power users can implement custom preprocessing. + Power users can implement custom preprocessing. Stateful context must stay + owed until ``AgentInput.commit_context`` is called to support rejected input. Note: PlatformEvent is a tagged union type: PlatformEvent = MessageEvent | RoomAddedEvent | RoomRemovedEvent | ... diff --git a/src/band/core/simple_adapter.py b/src/band/core/simple_adapter.py index e72d49488..682bd88dc 100644 --- a/src/band/core/simple_adapter.py +++ b/src/band/core/simple_adapter.py @@ -12,7 +12,7 @@ from band.client.rest import AsyncRestClient from band.client.streaming import ControlMode -from band.core.exceptions import BandConfigError +from band.core.exceptions import BandConfigError, InputNotAccepted from band.core.model_catalog import ( ModelCatalog, ModelSelection, @@ -380,6 +380,12 @@ def judges_turns(self) -> bool: """ return True + async def try_handle_control( + self, msg: PlatformMessage, tools: AgentToolsProtocol + ) -> bool: + """Consume an active-turn control without starting or rebinding work.""" + return False + async def on_event(self, inp: AgentInput) -> None: """Implements FrameworkAdapter.on_event(). @@ -432,15 +438,19 @@ async def run_judged_turn( ) # Positional, as ``on_message`` declares them: an override may rename # its positional parameters. - await self.on_message( - msg, - tools, - history, - participants_msg, - contacts_msg, - is_session_bootstrap=is_session_bootstrap, - room_id=room_id, - ) + try: + await self.on_message( + msg, + tools, + history, + participants_msg, + contacts_msg, + is_session_bootstrap=is_session_bootstrap, + room_id=room_id, + ) + except InputNotAccepted: + turn.judged = False + raise if ( turn.judged and not turn.detached diff --git a/src/band/core/types.py b/src/band/core/types.py index 1addf53d0..e02f38013 100644 --- a/src/band/core/types.py +++ b/src/band/core/types.py @@ -529,6 +529,8 @@ class AgentInput: Contains everything an adapter needs to process a message. History is provided via HistoryProvider for lazy conversion. + Custom preprocessors that consume context can supply ``commit_context``; + the host calls it after every attempt except proven non-acceptance. """ msg: PlatformMessage @@ -538,3 +540,16 @@ class AgentInput: contacts_msg: str | None # Contact changes broadcast message is_session_bootstrap: bool room_id: str + commit_context: Callable[[], None] | None = None + + +def delivery_status_for_agent(metadata: Any, agent_id: str | None) -> str | None: + """Read an agent's authoritative delivery status from wire metadata.""" + statuses = metadata_to_dict(metadata).get("delivery_status") + if not isinstance(statuses, dict): + return None + delivery = statuses.get(agent_id) + if not isinstance(delivery, dict): + return None + status = delivery.get("status") + return status if isinstance(status, str) else None diff --git a/src/band/integrations/acp/client_adapter.py b/src/band/integrations/acp/client_adapter.py index 909a37fd1..3ec3519ca 100644 --- a/src/band/integrations/acp/client_adapter.py +++ b/src/band/integrations/acp/client_adapter.py @@ -40,7 +40,7 @@ from band.converters.helpers import build_replay_messages from band.core.adapterconfig import BaseAdapterConfig from band.core.delivery import DeliveryFailedError, reraise_delivery_cause -from band.core.exceptions import BandConfigError +from band.core.exceptions import BandConfigError, InputNotAccepted from band.core.model_catalog import ModelSelection from band.core.protocols import ( FAILURE_CODE_TIMEOUT, @@ -152,13 +152,15 @@ class SessionInitializer: _PROVIDER = "acp" -@dataclass(frozen=True) +@dataclass class RoomSession: """A room's ACP session and the Band MCP URL it dials, ``None`` when it dials none.""" session_id: str band_url: str | None + bootstrap_pending: bool = True + replay_pending: bool = True @dataclass(frozen=True) @@ -427,7 +429,6 @@ def __init__( self._room_tools: dict[str, AgentToolsProtocol] = {} self._background_tasks: set[asyncio.Task[None]] = set() self._mcp = SharedBandMCPBackend(self._mcp_settings) - self._bootstrapped_sessions: set[str] = set() self._session_lock = asyncio.Lock() @property @@ -640,7 +641,7 @@ async def on_message( self._room_tools[room_id] = tools try: - session_id, created = await self._get_or_create_session( + session_id, _ = await self._get_or_create_session( runtime, room_id, history if is_session_bootstrap else None, @@ -658,8 +659,9 @@ async def on_message( # so seed it with the Band room's transcript. On bootstrap the converter # carried it; a session minted later (the previous runtime was torn down # mid-run) re-fetches it. + session = self._room_to_session[room_id] replay: list[str] | None = None - if created: + if session.bootstrap_pending and session.replay_pending: replay = ( history.replay_messages if is_session_bootstrap @@ -704,6 +706,16 @@ async def on_message( prompt_text=prompt_text, on_chunk=emitter.emit, ) + session.bootstrap_pending = False + session.replay_pending = False + except RequestError as error: + if ( + error.code == -32003 + and isinstance(error.data, dict) + and error.data.get("reason") == "session_busy" + ): + raise InputNotAccepted("ACP session is busy") from error + raise except asyncio.CancelledError: # Cancelling the local request leaves the agent running the # prompt; it only stops on session/cancel. @@ -737,6 +749,8 @@ async def on_message( reraise_delivery_cause(e) except ACPTurnTimeoutError: raise + except InputNotAccepted: + raise except Exception as e: logger.exception("ACP agent error") await self.on_cleanup(room_id) @@ -1054,7 +1068,6 @@ async def _get_or_create_session( return stale.session_id, False if stale is not None: del self._room_to_session[room_id] - self._bootstrapped_sessions.discard(stale.session_id) # Never restore: the persisted id may be the one being retired. history = None initializer = self._session_initializers.get(room_id) @@ -1166,7 +1179,10 @@ async def _restore_session( ) raise await self._record_session( - room_id, RoomSession(session_id=session_id, band_url=mcp.band_url) + room_id, + RoomSession( + session_id=session_id, band_url=mcp.band_url, replay_pending=False + ), ) logger.debug("Loaded ACP session mapping: %s -> %s", room_id, session_id) return session_id @@ -1406,22 +1422,6 @@ async def _report_config_error( ) ) - def _claim_session_bootstrap(self, session_id: str) -> bool: - """True exactly once per session — the caller owns the bootstrap prompt. - - Lock-free: the check-and-add runs without an ``await``, so the event - loop's run-to-completion makes it atomic. ``on_cleanup``, - ``cleanup_all`` and ``_get_or_create_session`` mutate this same set - under ``_session_lock`` instead — also safe today for the same - no-``await``-in-between reason, not because of the lock. Adding an - ``await`` before any of these mutations would need a real lock added - back everywhere ``_bootstrapped_sessions`` is touched. - """ - if session_id in self._bootstrapped_sessions: - return False - self._bootstrapped_sessions.add(session_id) - return True - def _system_update_sections( self, participants_msg: str | None, contacts_msg: str | None ) -> list[str]: @@ -1465,7 +1465,7 @@ def _build_prompt_text( live_message = msg.format_for_llm() system_updates = self._system_update_sections(participants_msg, contacts_msg) - if not self._claim_session_bootstrap(session_id): + if not self._room_to_session[room_id].bootstrap_pending: return "\n\n".join([*system_updates, live_message]) sections = [self._build_system_context(room_id, msg), *system_updates] @@ -1493,8 +1493,6 @@ async def on_cleanup( session = self._room_to_session.pop(room_id, None) initializer = self._session_initializers.pop(room_id, None) self._room_tools.pop(room_id, None) - if session is not None: - self._bootstrapped_sessions.discard(session.session_id) runtime = self._runtimes.pop(room_id, None) self._workspaces.release(room_id) @@ -1532,7 +1530,6 @@ async def cleanup_all(self, *, final: bool = True) -> None: self._session_initializers.clear() self._room_to_session.clear() self._room_tools.clear() - self._bootstrapped_sessions.clear() runtimes = list(self._runtimes.values()) self._runtimes.clear() for room_id in self._workspaces.rooms: diff --git a/src/band/integrations/base.py b/src/band/integrations/base.py index 2872f09b8..288af5dd6 100644 --- a/src/band/integrations/base.py +++ b/src/band/integrations/base.py @@ -49,7 +49,9 @@ from band.runtime.execution import ExecutionContext -def check_and_format_participants(ctx: ExecutionContext) -> str | None: +def check_and_format_participants( + ctx: ExecutionContext, *, mark_sent: bool = True +) -> str | None: """ Check if participants changed and return formatted message if so. @@ -73,5 +75,6 @@ def check_and_format_participants(ctx: ExecutionContext) -> str | None: return None msg = build_participants_message(ctx.participants) - ctx.mark_participants_sent() + if mark_sent: + ctx.mark_participants_sent() return msg diff --git a/src/band/platform/link.py b/src/band/platform/link.py index 3d1e6b7a9..2167f56f9 100644 --- a/src/band/platform/link.py +++ b/src/band/platform/link.py @@ -38,7 +38,7 @@ RoomRemovedEvent, WebSocketDisconnectedEvent, ) -from band.platform.message_lifecycle import MessageLifecycle +from band.platform.message_lifecycle import ActionablePage, MessageLifecycle from band.platform.subscriptions import SubscriptionManager from band.runtime.types import PlatformMessage @@ -589,6 +589,26 @@ async def report_activity( async def get_next_message(self, room_id: str) -> PlatformMessage | None: return await self._messages.get_next_message(self.rest, room_id) + async def get_actionable_page( + self, room_id: str, *, cursor: str | None = None + ) -> ActionablePage: + return await self._messages.get_actionable_page( + self.rest, room_id, cursor=cursor + ) + + async def get_actionable_message( + self, room_id: str, message_id: str + ) -> PlatformMessage | None: + cursor: str | None = None + while True: + page = await self.get_actionable_page(room_id, cursor=cursor) + for message in page.messages: + if message.id == message_id: + return message + if page.next_cursor is None: + return None + cursor = page.next_cursor + async def get_stale_processing_messages( self, room_id: str ) -> list[PlatformMessage]: diff --git a/src/band/platform/message_lifecycle.py b/src/band/platform/message_lifecycle.py index b8d39ecb4..a5f5c06fa 100644 --- a/src/band/platform/message_lifecycle.py +++ b/src/band/platform/message_lifecycle.py @@ -7,9 +7,10 @@ from __future__ import annotations import logging +from dataclasses import dataclass from datetime import UTC, datetime from http import HTTPStatus -from typing import TypeVar +from typing import Any, TypeVar from band_rest.core.api_error import ApiError from band_rest.core.http_response import AsyncHttpResponse @@ -63,6 +64,15 @@ def _platform_message(item: ChatMessage, room_id: str) -> PlatformMessage: ) +ACTIONABLE_PAGE_SIZE = 100 + + +@dataclass(frozen=True) +class ActionablePage: + messages: list[PlatformMessage] + next_cursor: str | None + + class MessageLifecycle: """Message mark/report/fetch operations for one agent's REST client. @@ -233,6 +243,27 @@ async def get_next_message( return None return _platform_message(_accepted_response(response).data, room_id) + async def get_actionable_page( + self, rest: AsyncRestClient, room_id: str, *, cursor: str | None = None + ) -> ActionablePage: + """Read one chronological page of outstanding deliveries; failures propagate.""" + kwargs: dict[str, Any] = {"cursor": cursor} if cursor is not None else {} + response = await rest.agent_api_messages.list_agent_messages( + chat_id=room_id, + sort_order="asc", + limit=ACTIONABLE_PAGE_SIZE, + request_options=DEFAULT_REQUEST_OPTIONS, + **kwargs, + ) + next_cursor = ( + response.metadata.next_cursor if response.metadata.has_more else None + ) + if response.metadata.has_more and next_cursor is None: + raise ValueError("Actionable messages page has_more without next_cursor") + return ActionablePage( + [_platform_message(item, room_id) for item in response.data], next_cursor + ) + async def get_stale_processing_messages( self, rest: AsyncRestClient, room_id: str ) -> list[PlatformMessage]: diff --git a/src/band/preprocessing/default.py b/src/band/preprocessing/default.py index 5671518a4..3b3dc6483 100644 --- a/src/band/preprocessing/default.py +++ b/src/band/preprocessing/default.py @@ -45,6 +45,51 @@ async def process( agent_id: str, ) -> AgentInput | None: """Process platform event into AgentInput.""" + msg = self.message(ctx, event, agent_id) + if msg is None: + return None + + is_bootstrap = not ctx.is_llm_initialized + + # Load history on session bootstrap (if hydration enabled) + raw_history: list[dict[str, Any]] = [] + if is_bootstrap and ctx.config.enable_context_hydration: + raw_history = await self._load_history(ctx, msg) + + # Check participants + participants = ctx.participants + participants_msg = check_and_format_participants(ctx, mark_sent=False) + + # Get pending system messages (contact broadcasts) + system_messages = ctx.peek_pending_system_messages() + contacts_msg = "\n".join(system_messages) if system_messages else None + + def commit_context() -> None: + if is_bootstrap: + ctx.mark_llm_initialized() + if participants_msg is not None: + ctx.mark_participants_sent(participants) + ctx.commit_system_messages(system_messages) + + # Create tools + tools = AgentTools.from_context(ctx) + + return AgentInput( + msg=msg, + tools=tools, + history=HistoryProvider(raw=raw_history), + participants_msg=participants_msg, + contacts_msg=contacts_msg, + is_session_bootstrap=is_bootstrap, + room_id=msg.room_id, + commit_context=commit_context, + ) + + @staticmethod + def message( + ctx: ExecutionContext, event: PlatformEvent, agent_id: str + ) -> PlatformMessage | None: + """Project a delivery without consuming model context.""" # Pattern match on tagged union - only handle MessageEvent match event: case MessageEvent(room_id=room_id, payload=msg_data): @@ -67,13 +112,13 @@ async def process( return None # Look up sender name from participants list - sender_name = self._lookup_sender_name(ctx, msg_data.sender_id) + sender_name = DefaultPreprocessor._lookup_sender_name(ctx, msg_data.sender_id) # Convert to PlatformMessage (typed attribute access, no dict lookups). # Translate @[[uuid]] mention tokens to @handle like history formatting # does: the platform normalizes mentions to uuid form, and a raw uuid in # the turn misleads the LLM (e.g. into using it as a memory subject_id). - msg = PlatformMessage( + return PlatformMessage( id=msg_data.id, room_id=room_id, content=replace_uuid_mentions(msg_data.content, ctx.participants), @@ -85,46 +130,8 @@ async def process( created_at=datetime.fromisoformat(msg_data.inserted_at), ) - is_bootstrap = not ctx.is_llm_initialized - - # Load history on session bootstrap (if hydration enabled) - raw_history: list[dict[str, Any]] = [] - if is_bootstrap: - if ctx.config.enable_context_hydration: - raw_history = await self._load_history(ctx, msg) - ctx.mark_llm_initialized() - - # Check participants - participants_msg = check_and_format_participants(ctx) - - # Get pending system messages (contact broadcasts) - contacts_msg = self._drain_system_messages(ctx) - - # Create tools - tools = AgentTools.from_context(ctx) - - return AgentInput( - msg=msg, - tools=tools, - history=HistoryProvider(raw=raw_history), - participants_msg=participants_msg, - contacts_msg=contacts_msg, - is_session_bootstrap=is_bootstrap, - room_id=room_id, - ) - - def _drain_system_messages(self, ctx: ExecutionContext) -> str | None: - """Drain pending system messages from context. - - Returns: - Combined system messages as a single string, or None if no messages. - """ - messages = ctx.get_pending_system_messages() - if not messages: - return None - return "\n".join(messages) - - def _lookup_sender_name(self, ctx: ExecutionContext, sender_id: str) -> str | None: + @staticmethod + def _lookup_sender_name(ctx: ExecutionContext, sender_id: str) -> str | None: """Look up sender name from participants list by sender_id.""" for participant in ctx.participants: if participant.get("id") == sender_id: diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index 6272e33f7..ec8cf0231 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -18,6 +18,7 @@ import contextlib import logging from asyncio import timeout as asyncio_timeout +from collections import deque from collections.abc import Awaitable, Callable, Iterator from datetime import UTC, datetime from enum import Enum, StrEnum @@ -43,13 +44,17 @@ MessageCreatedPayload, MessageMetadata, ) -from band.core.exceptions import RoomExecutionStoppedError +from band.core.exceptions import InputNotAccepted, RoomExecutionStoppedError from band.core.protocols import ( GENERIC_PROVIDER_FAILURE_MESSAGE, TURN_FAILURE_PROVIDER, TurnResultAlreadyReported, ) -from band.core.types import is_contact_hub_turn, metadata_to_dict +from band.core.types import ( + delivery_status_for_agent, + is_contact_hub_turn, + metadata_to_dict, +) from band.logging_config import TRACE_CONTEXT from band.platform.event import ( MessageEvent, @@ -99,6 +104,7 @@ class ResyncRequest: class BacklogProcessResult(Enum): ADVANCED = "advanced" RETRY_LATER = "retry_later" + NOT_ACCEPTED = "not_accepted" class ResyncOutcome(Enum): @@ -261,6 +267,8 @@ def __init__( *, hub_room_id: str | None = None, claim_registry: ClaimRegistry | None = None, + on_message_control: Callable[[ExecutionContext, PlatformEvent], Awaitable[bool]] + | None = None, on_platform_stop: Callable[[ExecutionContext, TurnScope], Awaitable[None]] | None = None, ): @@ -286,6 +294,7 @@ def __init__( self.room_id = room_id self.link = link self._on_execute = on_execute + self._on_message_control = on_message_control self.config = config or SessionConfig() self._agent_id = agent_id self._on_participant_added = on_participant_added @@ -335,6 +344,9 @@ def __init__( self._first_ws_msg_id: str | None = None # First WS message = sync point self._retry_tracker = RetryTracker(max_retries=self.config.max_message_retries) self._sync_complete = False # True after sync with /next completes + self._deferred_message_id: str | None = None + self._pending_synthetic: deque[MessageEvent] = deque() + self._control_cursor: str | None = None # Graceful shutdown: event signaled when state becomes idle self._idle_event: asyncio.Event = asyncio.Event() @@ -442,20 +454,7 @@ def mark_llm_initialized(self) -> None: def _delivery_status_for_agent(self, metadata: Any) -> str | None: """Return this agent's delivery status from message metadata.""" - if not self._agent_id: - return None - - metadata_dict = metadata_to_dict(metadata) - delivery_status = metadata_dict.get("delivery_status") - if not isinstance(delivery_status, dict): - return None - - agent_status = delivery_status.get(self._agent_id) - if not isinstance(agent_status, dict): - return None - - status = agent_status.get("status") - return status if isinstance(status, str) else None + return delivery_status_for_agent(metadata, self._agent_id) def _context_message_metadata(self, message_id: str) -> dict[str, Any] | None: """Return metadata for a hydrated context message by ID.""" @@ -862,9 +861,12 @@ def participants_changed(self) -> bool: refreshed in place (e.g. a description learned after it first joined).""" return self._roster.changed() - def mark_participants_sent(self) -> None: - """Mark current participants as sent to LLM.""" - self._roster.mark_sent() + def mark_participants_sent( + self, snapshot: list[dict[str, Any]] | None = None + ) -> None: + """Commit a transmitted roster only while that snapshot is current.""" + if snapshot is None or self.participants == snapshot: + self._roster.mark_sent() def inject_system_message(self, message: str) -> None: """ @@ -883,6 +885,15 @@ def inject_system_message(self, message: str) -> None: message[:50], ) + def peek_pending_system_messages(self) -> list[str]: + """Prepare pending context without consuming it.""" + return self._pending_system_messages.copy() + + def commit_system_messages(self, prepared: list[str]) -> None: + """Consume only the prepared prefix, preserving concurrent arrivals.""" + if self._pending_system_messages[: len(prepared)] == prepared: + del self._pending_system_messages[: len(prepared)] + def get_pending_system_messages(self) -> list[str]: """ Get and clear pending system messages. @@ -1125,16 +1136,13 @@ async def _notify_participant_removed(self, event: ParticipantRemovedEvent) -> N async def _process_loop(self) -> None: """Synchronize startup, then serialize queued events and due recovery.""" try: - # Phase 1: Sync via /next until we catch up with WebSocket. - # If a pending message cannot be claimed yet, stay in startup sync - # instead of processing newer WebSocket events out of order. - while not await self._synchronize_with_next(): - self._set_state(ExecutionState.IDLE) - await asyncio.sleep(self.config.idle_resync_seconds) + # A blocked startup yields to the scheduler so controls can reach + # active work without letting newer ordinary messages run. + await self._synchronize_with_next() self._set_state(ExecutionState.IDLE) logger.info( - "ExecutionContext %s: Synchronized, switching to WebSocket", + "ExecutionContext %s: Startup pass finished; servicing queue and recovery", self.room_id, ) @@ -1159,7 +1167,15 @@ async def _process_loop(self) -> None: if event is self._pending_resync: await self._reconcile() continue - if await self._process_event(event) is False: + if isinstance(event, MessageEvent) and ( + not self._sync_complete or self._has_pending_work + ): + await self._retain_or_handle_control(event) + continue + if ( + await self._process_event(event) is False + and not self._has_pending_work + ): await self._reconcile() except asyncio.CancelledError: @@ -1168,6 +1184,126 @@ async def _process_loop(self) -> None: logger.exception("ExecutionContext %s error", self.room_id) logger.debug("ExecutionContext %s loop exited", self.room_id) + @property + def _has_pending_work(self) -> bool: + return self._deferred_message_id is not None or bool(self._pending_synthetic) + + def _set_deferred_head(self, message_id: str | None) -> None: + if message_id != self._deferred_message_id: + self._deferred_message_id = message_id + self._control_cursor = None + + def _retain_synthetic( + self, event: PlatformEvent | None, *, first: bool = False + ) -> bool: + if not isinstance(event, MessageEvent) or event.payload is None: + return False + payload = event.payload + if not is_contact_hub_turn( + sender_type=payload.sender_type, sender_id=payload.sender_id + ): + return False + if not any( + item.payload is not None and item.payload.id == payload.id + for item in self._pending_synthetic + ): + if first: + self._pending_synthetic.appendleft(event) + else: + self._pending_synthetic.append(event) + return True + + async def _retain_or_handle_control(self, event: MessageEvent) -> None: + if self._retain_synthetic(event): + return + if not self._stopped and self._on_message_control is not None: + await self._scan_controls() + + async def _scan_controls(self) -> None: + if self._on_message_control is None: + return + revision = self._control_revision + try: + page = await self.link.get_actionable_page( + self.room_id, cursor=self._control_cursor + ) + if revision != self._control_revision: + return + for msg in page.messages: + if self._stopped or revision != self._control_revision: + return + if msg.id != self._deferred_message_id: + await self._try_control(_backlog_event(msg)) + self._control_cursor = page.next_cursor + except RoomExecutionStoppedError: + return + except Exception: + logger.exception("Control recovery failed for room %s", self.room_id) + + async def _try_control(self, event: MessageEvent) -> bool: + """Probe a gated candidate without invoking or charging model work.""" + if self._on_message_control is None or event.payload is None: + return False + payload = event.payload + msg_id = payload.id + if self._agent_id and is_self_echo( + sender_id=payload.sender_id or "", + sender_type=payload.sender_type or "", + agent_id=self._agent_id, + ): + return False + if self.claims.is_completed( + self.room_id, msg_id + ) or self._retry_tracker.is_permanently_failed(msg_id): + return False + if self.claims.is_ack_pending(self.room_id, msg_id): + await self._retry_processed_ack(msg_id) + return True + if self._message_processed_for_agent(msg_id, payload.metadata): + self.claims.remember_completed(self.room_id, msg_id) + return False + with self._claim_message(msg_id) as acquired: + if not acquired or not await self._begin_scope(): + return False + revision = self._control_revision + consumed = False + + async def probe() -> None: + nonlocal consumed + consumed = await self._on_message_control(self, event) + + try: + self._cycle_armed = True + if not await self.link.mark_processing(self.room_id, msg_id): + return False + await self._ensure_fresh_context() + if revision != self._control_revision: + return False + if ( + not await self._run_cycle(event, msg_id, handler=probe) + or not consumed + ): + return False + self.claims.remember_ack_pending(self.room_id, msg_id) + if await self.link.mark_processed(self.room_id, msg_id): + self._retry_tracker.mark_success(msg_id) + self.claims.remember_completed(self.room_id, msg_id) + return True + except RoomExecutionStoppedError: + return False + except Exception as error: # noqa: BLE001 -- control boundary uses the normal delivery-failure policy + _log_turn_error(error, "Error handling control message %s", msg_id) + attempts, _ = self._retry_tracker.record_attempt(msg_id) + await self._handle_turn_failure(msg_id, attempts, error) + if attempts >= self._retry_tracker.max_retries: + self._retry_tracker.mark_permanently_failed(msg_id) + return False + finally: + self._cycle_armed = False + self._pending_interrupt = None + self._turn_failure_reported = False + self._set_state(ExecutionState.IDLE) + async def _retry_pending_processed_acks(self) -> bool: """Retry durable processed acks for locally completed messages.""" for msg_id in self.claims.pending_ack_ids(self.room_id): @@ -1178,7 +1314,16 @@ async def _retry_pending_processed_acks(self) -> bool: async def _reconcile(self) -> None: # Requests received after this pass starts retain their own sentinel. self._pending_resync = None - outcome = await self._wait_until_resync_complete() + if ( + not self._sync_complete + and not self._stopped + and not await self._synchronize_with_next() + ): + outcome = ResyncOutcome.BLOCKED + else: + outcome = await self._wait_until_resync_complete() + if self._has_pending_work and not self._stopped: + await self._scan_controls() self._resync_schedule.complete( asyncio.get_running_loop().time(), empty=outcome is ResyncOutcome.EMPTY and not self._stopped, @@ -1186,38 +1331,33 @@ async def _reconcile(self) -> None: async def _wait_until_resync_complete(self) -> ResyncOutcome: """Preserve recovery ordering while allowing stopped-room discovery.""" - handled_work = False - while True: - candidate: PlatformMessage | None = None + candidate: PlatformMessage | None = None + revision = self._control_revision + if self._stopped: + try: + candidate = await self._get_next_message() + except Exception: + logger.exception("Stopped-room probe failed for %s", self.room_id) + return ResyncOutcome.RETRY + if revision != self._control_revision: + return ResyncOutcome.BLOCKED + if candidate is None: + return ResyncOutcome.STOPPED + if self._backlog_is_nonrunnable(candidate): + return ResyncOutcome.STOPPED + self._resume_room() revision = self._control_revision - if self._stopped: - try: - candidate = await self._get_next_message() - except Exception: - logger.exception("Stopped-room probe failed for %s", self.room_id) - return ResyncOutcome.RETRY - if revision != self._control_revision: - return ResyncOutcome.BLOCKED - if candidate is None: - return ResyncOutcome.STOPPED - if self._backlog_is_nonrunnable(candidate): - return ResyncOutcome.STOPPED - self._resume_room() - revision = self._control_revision - if await self._retry_pending_processed_acks(): - if revision != self._control_revision: - return ResyncOutcome.BLOCKED - outcome = await self._resync_pending_messages(candidate) - handled_work = handled_work or self._resync_handled_work - if outcome is not ResyncOutcome.RETRY: - if outcome is ResyncOutcome.EMPTY and handled_work: - return ResyncOutcome.WORK - return outcome - if self._stopped: - return ResyncOutcome.STOPPED - self._set_state(ExecutionState.IDLE) - await asyncio.sleep(self.config.idle_resync_seconds) + if await self._retry_pending_processed_acks(): + if revision != self._control_revision: + return ResyncOutcome.BLOCKED + outcome = await self._resync_pending_messages(candidate) + if outcome is not ResyncOutcome.RETRY: + return outcome + if self._stopped: + return ResyncOutcome.STOPPED + self._set_state(ExecutionState.IDLE) + return ResyncOutcome.RETRY async def _synchronize_with_next(self) -> bool: """ @@ -1273,7 +1413,11 @@ async def _synchronize_with_next(self) -> bool: result = await self._process_backlog_message( next_msg, revision=revision ) - if result is BacklogProcessResult.RETRY_LATER: + if result in { + BacklogProcessResult.RETRY_LATER, + BacklogProcessResult.NOT_ACCEPTED, + }: + self._set_deferred_head(next_msg.id) return False if at_sync_point: # Preserve other queued events when removing the sync point. @@ -1326,6 +1470,11 @@ async def _resync_pending_messages( ) -> ResyncOutcome: """Drain actionable work, distinguishing empty from blocked recovery.""" self._resync_handled_work = False + if self._deferred_message_id is None: + while self._pending_synthetic: + event = self._pending_synthetic.popleft() + if not await self._process_event(event): + return ResyncOutcome.BLOCKED previous_id: str | None = None try: while True: @@ -1337,6 +1486,9 @@ async def _resync_pending_messages( if revision != self._control_revision: return ResyncOutcome.BLOCKED if next_msg is None: + self._set_deferred_head(None) + if self._pending_synthetic: + return ResyncOutcome.BLOCKED return ( ResyncOutcome.WORK if self._resync_handled_work @@ -1355,8 +1507,15 @@ async def _resync_pending_messages( return ResyncOutcome.STOPPED if revision != self._control_revision: return ResyncOutcome.BLOCKED + if result is BacklogProcessResult.NOT_ACCEPTED: + return ResyncOutcome.BLOCKED if result is BacklogProcessResult.RETRY_LATER: + self._set_deferred_head(next_msg.id) return ResyncOutcome.RETRY + if next_msg.id == self._deferred_message_id: + self._set_deferred_head(None) + if self._pending_synthetic: + return ResyncOutcome.BLOCKED except Exception: logger.exception( "ExecutionContext %s: Error during /next resync", self.room_id @@ -1483,49 +1642,7 @@ async def _process_claimed_backlog_message( if revision != self._control_revision: return await self._defer_superseded_backlog(msg_id) - # Format timestamps for MessageCreatedPayload validation - created_at_str = ( - msg.created_at.isoformat() - if msg.created_at - else datetime.now(UTC).isoformat() - ) - - # Normalize metadata.mentions to include username field - metadata = metadata_to_dict(msg.metadata).copy() - if "mentions" in metadata: - normalized_mentions = [] - for mention in metadata.get("mentions", []): - if isinstance(mention, dict): - normalized_mentions.append( - { - "id": mention.get("id", ""), - "username": mention.get("username") - or mention.get("name") - or mention.get("id", ""), - } - ) - metadata["mentions"] = normalized_mentions - else: - metadata["mentions"] = [] - - if "status" not in metadata: - metadata["status"] = "sent" - - # Create event from message for handler - event = MessageEvent( - room_id=self.room_id, - payload=MessageCreatedPayload( - id=msg.id, - content=msg.content, - sender_id=msg.sender_id, - sender_type=msg.sender_type, - message_type=msg.message_type, - metadata=MessageMetadata(**metadata), - chat_room_id=self.room_id, - inserted_at=created_at_str, - updated_at=created_at_str, - ), - ) + event = _backlog_event(msg) # Call execution handler as a cancellable cycle (backlog messages # are always reasoning cycles, so the working signal is always @@ -1560,6 +1677,11 @@ async def _process_claimed_backlog_message( logger.debug("Message %s processed successfully", msg_id) return BacklogProcessResult.ADVANCED + except InputNotAccepted: + self._retry_tracker.discard_attempt(msg_id) + self._set_deferred_head(msg_id) + return BacklogProcessResult.NOT_ACCEPTED + except RoomExecutionStoppedError: if not self.claims.is_ack_pending(self.room_id, msg_id): self._retry_tracker.discard_attempt(msg_id) @@ -1620,6 +1742,10 @@ async def _invoke_handler(self, event: PlatformEvent) -> None: housekeeping and skip it. """ if isinstance(event, MessageEvent): + if self._on_message_control is not None and await self._on_message_control( + self, event + ): + return await self._execute_message_cycle(event) else: await self._on_execute(self, event) @@ -1707,7 +1833,13 @@ async def _drain_cancelled_cycle_task(self, task: asyncio.Task[None]) -> None: self.room_id, ) - async def _run_cycle(self, event: PlatformEvent, msg_id: str | None) -> bool: + async def _run_cycle( + self, + event: PlatformEvent, + msg_id: str | None, + *, + handler: Callable[[], Awaitable[None]] | None = None, + ) -> bool: """Run the execution handler as a cancellable child task. Wrapping the cycle in its own task lets a control signal abort just this @@ -1741,9 +1873,15 @@ async def _run_cycle(self, event: PlatformEvent, msg_id: str | None) -> bool: self._pending_interrupt = None self._cycle_armed = False if pending is not None: - return await self._abort_cycle(pending, msg_id) + return await self._abort_cycle(pending, msg_id, event=event) + + async def invoke() -> None: + if handler is not None: + await handler() + else: + await self._invoke_handler(event) - self._active_cycle_task = asyncio.create_task(self._invoke_handler(event)) + self._active_cycle_task = asyncio.create_task(invoke()) try: if self.config.max_cycle_seconds is not None: # Bound outside the try: __aenter__ raising before this binds @@ -1807,7 +1945,7 @@ async def _run_cycle(self, event: PlatformEvent, msg_id: str | None) -> bool: # timeout failure. kind = self._take_interrupt_kind(scope) if kind is not None: - return await self._abort_cycle(kind, msg_id) + return await self._abort_cycle(kind, msg_id, event=event) # Replace whatever bare TimeoutError arrived # (asyncio.timeout's own conversion carries no # message) with one mark_failed can show the user, @@ -1826,7 +1964,7 @@ async def _run_cycle(self, event: PlatformEvent, msg_id: str | None) -> bool: if parent is not None and parent.cancelling(): raise asyncio.CancelledError if kind is not None and scope is not None and scope.stop_observed: - return await self._abort_cycle(kind, msg_id) + return await self._abort_cycle(kind, msg_id, event=event) return True except asyncio.CancelledError: # If two control signals raced before this ran, last-writer-wins on @@ -1848,7 +1986,7 @@ async def _run_cycle(self, event: PlatformEvent, msg_id: str | None) -> bool: raise if cycle_task is not None: await self._drain_cancelled_cycle_task(cycle_task) - return await self._abort_cycle(kind, msg_id) + return await self._abort_cycle(kind, msg_id, event=event) finally: self._active_cycle_task = None @@ -1895,7 +2033,13 @@ async def _claim(self, msg_id: str) -> bool: self._retry_tracker.discard_attempt(msg_id) return False - async def _abort_cycle(self, kind: ControlMode, msg_id: str | None) -> bool: + async def _abort_cycle( + self, + kind: ControlMode, + msg_id: str | None, + *, + event: PlatformEvent | None = None, + ) -> bool: """Unwind an aborted cycle (interrupt/stop): drop work, send nothing. Shared by the in-flight cancel path (``_run_cycle``'s ``CancelledError`` @@ -1911,6 +2055,8 @@ async def _abort_cycle(self, kind: ControlMode, msg_id: str | None) -> bool: if msg_id: self._retry_tracker.discard_attempt(msg_id) await self._clear_activity() + if kind is ControlMode.STOP: + self._retain_synthetic(event, first=True) 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 @@ -1972,6 +2118,7 @@ async def _process_event(self, event: PlatformEvent) -> bool: # never reaches the adapter (reply->403). The message is left untouched # so /next replays it on play. if self._stopped and isinstance(event, MessageEvent): + self._retain_synthetic(event) logger.debug( "ExecutionContext %s: stopped, skipping message %s (left for replay)", self.room_id, @@ -2033,7 +2180,10 @@ async def _process_event(self, event: PlatformEvent) -> bool: return False return await self._process_event_body(event, msg_id, payload) - return await self._process_event_body(event, msg_id, payload) + processed = await self._process_event_body(event, msg_id, payload) + if not processed: + self._retain_synthetic(event, first=True) + return processed def note_turn_failure_reported(self) -> None: """Record that the adapter already told the room this turn failed.""" @@ -2217,9 +2367,17 @@ async def _process_event_body( ) return False + if msg_id == self._deferred_message_id: + self._set_deferred_head(None) logger.debug("Event %s processed successfully", event.type) return True + except InputNotAccepted: + if msg_id: + self._retry_tracker.discard_attempt(msg_id) + self._set_deferred_head(msg_id) + return False + except RoomExecutionStoppedError: if msg_id and not self.claims.is_ack_pending(self.room_id, msg_id): self._retry_tracker.discard_attempt(msg_id) @@ -2239,3 +2397,45 @@ async def _process_event_body( self._pending_interrupt = None self._turn_failure_reported = False self._set_state(ExecutionState.IDLE) + + +def _backlog_event(msg: PlatformMessage) -> MessageEvent: + created_at_str = ( + msg.created_at.isoformat() if msg.created_at else datetime.now(UTC).isoformat() + ) + + # REST mentions may use name; the WebSocket projection requires username. + metadata = metadata_to_dict(msg.metadata).copy() + if "mentions" in metadata: + normalized_mentions = [] + for mention in metadata.get("mentions", []): + if isinstance(mention, dict): + normalized_mentions.append( + { + "id": mention.get("id", ""), + "username": mention.get("username") + or mention.get("name") + or mention.get("id", ""), + } + ) + metadata["mentions"] = normalized_mentions + else: + metadata["mentions"] = [] + + if "status" not in metadata: + metadata["status"] = "sent" + + return MessageEvent( + room_id=msg.room_id, + payload=MessageCreatedPayload( + id=msg.id, + content=msg.content, + sender_id=msg.sender_id, + sender_type=msg.sender_type, + message_type=msg.message_type, + metadata=MessageMetadata(**metadata), + chat_room_id=msg.room_id, + inserted_at=created_at_str, + updated_at=created_at_str, + ), + ) diff --git a/src/band/runtime/oneshot.py b/src/band/runtime/oneshot.py index d2baa799f..17166b9f2 100644 --- a/src/band/runtime/oneshot.py +++ b/src/band/runtime/oneshot.py @@ -46,21 +46,27 @@ from typing import Any, cast from band_sdk_core import ( + ClaimRegistry, DrainCandidate, evaluate_adapter_result, evaluate_delivery_event, evaluate_drain_candidate, - evaluate_next_message, ) from band.client.rest import DEFAULT_REQUEST_OPTIONS -from band.core.exceptions import BandConnectionError, RoomExecutionStoppedError +from band.client.streaming import DeliveryStatus +from band.core.exceptions import ( + BandConnectionError, + InputNotAccepted, + RoomExecutionStoppedError, +) from band.core.protocols import FrameworkAdapter from band.core.simple_adapter import SimpleAdapter from band.core.types import ( AgentInput, HistoryProvider, PlatformMessage, + delivery_status_for_agent, ) from band.logging_config import current_traceparent from band.platform.link import BandLink @@ -96,6 +102,7 @@ class OneShotStatus(StrEnum): NO_PENDING = "no_pending" ALREADY_PROCESSED = "already_processed" DONE = "done" + NOT_ACCEPTED = "not_accepted" # Defensive cap on the drain loop. The platform shouldn't backlog dozens of @@ -152,6 +159,7 @@ def __init__( drain_cap: int = DEFAULT_DRAIN_CAP, history_page_cap: int = DEFAULT_HISTORY_PAGE_CAP, ) -> None: + self._claims = ClaimRegistry() self._link = link self._adapter = adapter self._agent_id = agent_id @@ -324,6 +332,29 @@ async def _acknowledge( async def _process_message_event( self, *, room_id: str, payload: dict[str, Any] + ) -> dict[str, Any]: + msg_id = payload["id"] + if self._claims.is_completed(room_id, msg_id): + return {"status": OneShotStatus.ALREADY_PROCESSED, "message_id": msg_id} + if self._claims.is_ack_pending(room_id, msg_id): + await self._acknowledge(room_id=room_id, message_id=msg_id, succeeded=True) + self._claims.remember_completed(room_id, msg_id) + return { + "status": OneShotStatus.DONE, + "message_id": msg_id, + "room_id": room_id, + } + if not self._claims.try_claim(room_id, msg_id): + return {"status": OneShotStatus.NOT_ACCEPTED, "message_id": msg_id} + try: + return await self._process_claimed_message_event( + room_id=room_id, payload=payload + ) + finally: + self._claims.release(room_id, msg_id) + + async def _process_claimed_message_event( + self, *, room_id: str, payload: dict[str, Any] ) -> dict[str, Any]: """Run the SDK agent loop for one forwarded message_created event. @@ -349,28 +380,25 @@ async def _process_message_event( # ``processing`` from a previous crash — so a single call covers both # the normal claim case and stuck-message reclaim. next_msg = await self._link.get_next_message(room_id) - match evaluate_next_message(msg_id, next_msg.id if next_msg else None): - case {"decision": "no_pending"}: - logger.info( - "Skip: room %s has no pending messages (triggering=%s)", - room_id, - msg_id, - ) - return {"status": OneShotStatus.NO_PENDING, "message_id": msg_id} - case {"decision": "already_processed", "next_open_id": next_open_id}: - logger.info( - "Skip: room %s next-open=%s != triggering=%s", - room_id, - next_open_id, - msg_id, - ) + if next_msg is None: + return {"status": OneShotStatus.NO_PENDING, "message_id": msg_id} + ordered = next_msg.id == msg_id + candidate = next_msg + if not ordered: + candidate = await self._link.get_actionable_message(room_id, msg_id) + if candidate is None: return { "status": OneShotStatus.ALREADY_PROCESSED, "message_id": msg_id, - "next_open": next_open_id, + "next_open": next_msg.id, } - case {"decision": "ready_to_claim"}: - pass + + if ( + delivery_status_for_agent(candidate.metadata, self._agent_id) + == DeliveryStatus.PROCESSED + ): + self._claims.remember_completed(room_id, msg_id) + return {"status": OneShotStatus.ALREADY_PROCESSED, "message_id": msg_id} logger.info("Claiming msg %s in room %s", msg_id, room_id) if not await self._link.mark_processing(room_id, msg_id): @@ -380,18 +408,48 @@ async def _process_message_event( sender_name = _lookup_sender_name(participants, payload.get("sender_id")) msg = _build_platform_message(payload, room_id, sender_name, participants) + tools = AgentTools( + room_id=room_id, rest=self._link.rest, participants=participants + ) + control = getattr(self._adapter, "try_handle_control", None) + try: + consumed = control is not None and await control(msg, tools) + except RoomExecutionStoppedError: + raise + except Exception as exc: + logger.exception( + "Control failed for message %s in room %s", msg_id, room_id + ) + await self._acknowledge( + room_id=room_id, + message_id=msg_id, + succeeded=False, + error=str(exc)[:500] or "error", + ) + raise + if consumed: + self._claims.remember_ack_pending(room_id, msg_id) + await self._acknowledge(room_id=room_id, message_id=msg_id, succeeded=True) + self._claims.remember_completed(room_id, msg_id) + return { + "status": OneShotStatus.DONE, + "room_id": room_id, + "message_id": msg_id, + } + if not ordered: + return { + "status": OneShotStatus.ALREADY_PROCESSED, + "message_id": msg_id, + "next_open": next_msg.id, + } + history, seen_ids, history_truncated = await self._fetch_history( room_id, exclude_message_id=msg.id, participants=participants, ) - # The triggering message is always something the LLM "saw". seen_ids.add(msg_id) - tools = AgentTools( - room_id=room_id, rest=self._link.rest, participants=participants - ) - inp = AgentInput( msg=msg, tools=tools, @@ -410,6 +468,12 @@ async def _process_message_event( try: await self._adapter.on_event(inp) + except InputNotAccepted: + return { + "status": OneShotStatus.NOT_ACCEPTED, + "room_id": room_id, + "message_id": msg_id, + } except RoomExecutionStoppedError: raise except Exception as exc: @@ -424,7 +488,9 @@ async def _process_message_event( ) raise + self._claims.remember_ack_pending(room_id, msg_id) await self._acknowledge(room_id=room_id, message_id=msg_id, succeeded=True) + self._claims.remember_completed(room_id, msg_id) # Drain is scoped to what the LLM saw (seen_ids). A message that # arrived after the history snapshot is NOT swallowed; it's left open diff --git a/src/band/runtime/platform_runtime.py b/src/band/runtime/platform_runtime.py index f04cc523b..1b1e57a7c 100644 --- a/src/band/runtime/platform_runtime.py +++ b/src/band/runtime/platform_runtime.py @@ -169,6 +169,8 @@ async def start( on_execute: Callable[[ExecutionContext, PlatformEvent], Awaitable[None]], on_cleanup: Callable[[str], Awaitable[None]] | None = None, on_control: Callable[[str, ControlMode], Awaitable[None]] | None = None, + on_message_control: Callable[[ExecutionContext, PlatformEvent], Awaitable[bool]] + | None = None, ) -> None: """ Start platform runtime (begin processing messages). @@ -204,6 +206,7 @@ async def start( session_config=self._session_config, on_session_cleanup=on_cleanup or self._noop_cleanup, on_control=on_control, + on_message_control=on_message_control, on_participant_added=self._on_participant_added, on_participant_removed=self._on_participant_removed, ) diff --git a/src/band/runtime/runtime.py b/src/band/runtime/runtime.py index 4d859051c..64ea399de 100644 --- a/src/band/runtime/runtime.py +++ b/src/band/runtime/runtime.py @@ -101,6 +101,8 @@ def __init__( session_config: SessionConfig | None = None, on_session_cleanup: Callable[[str], Awaitable[None]] | None = None, on_control: Callable[[str, ControlMode], Awaitable[None]] | None = None, + on_message_control: Callable[[ExecutionContext, PlatformEvent], Awaitable[bool]] + | None = None, on_participant_added: ParticipantAddedCallback | None = None, on_participant_removed: ParticipantRemovedCallback | None = None, ): @@ -129,6 +131,7 @@ def __init__( self._session_config = session_config or SessionConfig() self._on_session_cleanup = on_session_cleanup self._on_control = on_control + self._on_message_control = on_message_control self._on_participant_added = on_participant_added self._on_participant_removed = on_participant_removed @@ -413,6 +416,7 @@ async def _create_execution(self, room_id: str) -> Execution: room_id=room_id, link=self.link, on_execute=self._on_execute, + on_message_control=self._on_message_control, config=self._session_config, agent_id=self.agent_id, on_participant_added=self._on_participant_added, diff --git a/tests/adapters/claude_sdk/approvals/test_manual_approval.py b/tests/adapters/claude_sdk/approvals/test_manual_approval.py index 8bf113d98..979f2c8c4 100644 --- a/tests/adapters/claude_sdk/approvals/test_manual_approval.py +++ b/tests/adapters/claude_sdk/approvals/test_manual_approval.py @@ -13,6 +13,7 @@ APPROVAL_TIMED_OUT_TEMPLATE, APPROVAL_UNAUTHORIZED_MESSAGE, ) +from band.core.exceptions import InputNotAccepted from band.testing import MISSING_REPLY_FAILURE, failure_reports from tests.adapters.claude_sdk.helpers import ( WRITE_NOTE, @@ -72,12 +73,10 @@ async def test_a_room_answers_each_gated_tool_while_its_commands_stay_local( assert room.failures == [] -async def test_messages_during_a_parked_turn_settle_on_their_own_tools( +async def test_controls_preserve_a_parked_turn_and_busy_work_is_unaccepted( claude_room: OpenRoom, ) -> None: - """While a turn waits on an approval, a command or a second request is - answered and settled on its own message's tools; the parked turn keeps - the tools it started with, and its prompt is never taken for its reply.""" + """Controls settle independently; unaccepted work cannot steal the reply.""" room = await claude_room(with_approvals()) room.claude.script([LIST_FILES, room.model_reply("Listed.")]) parked, status, busy, approve = (room.fresh_tools() for _ in range(4)) @@ -85,7 +84,8 @@ async def test_messages_during_a_parked_turn_settle_on_their_own_tools( await room.send("List the files", tools=parked) prompted_turn_replied = parked.turn.replied await room.send("/status", tools=status) - await room.send("And the hidden ones?", tools=busy) + with pytest.raises(InputNotAccepted): + await room.send("And the hidden ones?", tools=busy) await room.send("/approve", tools=approve) await room.settled() @@ -93,14 +93,19 @@ async def test_messages_during_a_parked_turn_settle_on_their_own_tools( del chat[1] # the status report assert chat == [ prompt("a-1", "Bash: `ls`"), - "Still processing the previous request in this room.", resolved("a-1", "accept"), "Listed.", ] + assert not busy.turn.complete assert prompted_turn_replied is False assert parked.turn.replied - for side_turn in (status.turn, busy.turn, approve.turn): + for side_turn in (status.turn, approve.turn): assert (side_turn.complete, side_turn.replied) == (True, False) + room.claude.script([room.model_reply("Hidden files listed.")]) + await room.send("And the hidden ones?", tools=busy) + await room.settled() + assert busy.turn.replied + assert len(room.claude.prompts) == 2 async def test_a_released_turn_that_ends_unanswered_is_reported_at_its_end( diff --git a/tests/adapters/claude_sdk/helpers.py b/tests/adapters/claude_sdk/helpers.py index d568eb9b8..5c541178e 100644 --- a/tests/adapters/claude_sdk/helpers.py +++ b/tests/adapters/claude_sdk/helpers.py @@ -27,6 +27,7 @@ from band.runtime.tools import BAND_MCP_SERVER_NAME, MCP_TOOL_PREFIX from band.testing import MISSING_REPLY_FAILURE, FakeAgentTools from tests.adapters.claude_sdk.fakecli import FakeClaude +from tests.adapters.dispatch import deliver_event from tests.baseline.decisions import ModelDecision from tests.mcpclient import crash_backend @@ -219,7 +220,8 @@ async def send( raw_history = [*history] if session_id is not None: raw_history.append(recorded_session(session_id)) - await self.adapter.on_event( + await deliver_event( + self.adapter, AgentInput( msg=PlatformMessage( id=f"msg-{next(self._message_ids)}", @@ -238,7 +240,7 @@ async def send( contacts_msg=None, is_session_bootstrap=bootstrap, room_id=self.room_id, - ) + ), ) def send_in_background(self, content: str) -> asyncio.Task[None]: diff --git a/tests/adapters/claude_sdk/lifecycle/test_on_message.py b/tests/adapters/claude_sdk/lifecycle/test_on_message.py index a140811ef..55340886a 100644 --- a/tests/adapters/claude_sdk/lifecycle/test_on_message.py +++ b/tests/adapters/claude_sdk/lifecycle/test_on_message.py @@ -3,6 +3,9 @@ import asyncio from collections.abc import Awaitable, Callable +import pytest + +from band.core.exceptions import InputNotAccepted from tests.adapters.claude_sdk.fakecli import Hold from tests.adapters.claude_sdk.helpers import ClaudeRoom, said @@ -37,17 +40,16 @@ async def test_a_room_bootstraps_once_then_keeps_talking_in_the_same_session( async def test_a_second_message_is_refused_while_the_first_is_still_running( claude_room: OpenRoom, ) -> None: - """One room runs one turn: a message arriving mid-turn is told to wait, - reaches no model, and the running turn still finishes normally.""" + """Unaccepted work reaches no model and the active turn finishes normally.""" room = await claude_room() thinking = Hold() room.claude.script([thinking, room.model_reply("done")]) first = asyncio.create_task(room.send("do the long thing")) async with thinking: - await room.send("are you done yet?") + with pytest.raises(InputNotAccepted): + await room.send("are you done yet?") await first - assert room.chat == ["Still processing the previous request in this room.", "done"] - assert room.tools.messages_sent[0]["mentions"] == ["u1"] + assert room.chat == ["done"] assert len(room.claude.prompts) == 1 diff --git a/tests/adapters/dispatch.py b/tests/adapters/dispatch.py new file mode 100644 index 000000000..ce9bc6d3e --- /dev/null +++ b/tests/adapters/dispatch.py @@ -0,0 +1,37 @@ +"""Control-first delivery at the adapter test boundary.""" + +from __future__ import annotations + +from typing import Any + +from band.core.protocols import AgentToolsProtocol +from band.core.simple_adapter import SimpleAdapter +from band.core.types import AgentInput, PlatformMessage + + +async def deliver_event(adapter: SimpleAdapter[Any], inp: AgentInput) -> None: + if not await adapter.try_handle_control(inp.msg, inp.tools): + await adapter.on_event(inp) + + +async def deliver_message( + adapter: SimpleAdapter[Any], + msg: PlatformMessage, + tools: AgentToolsProtocol, + history: Any, + participants_msg: str | None, + contacts_msg: str | None, + *, + is_session_bootstrap: bool, + room_id: str, +) -> None: + if not await adapter.try_handle_control(msg, tools): + await adapter.on_message( + msg, + tools, + history, + participants_msg, + contacts_msg, + is_session_bootstrap=is_session_bootstrap, + room_id=room_id, + ) diff --git a/tests/adapters/opencode/helpers.py b/tests/adapters/opencode/helpers.py index 7737dab5a..f6cbdab4a 100644 --- a/tests/adapters/opencode/helpers.py +++ b/tests/adapters/opencode/helpers.py @@ -26,6 +26,7 @@ from band.integrations.opencode.types import OpencodeSessionState from band.runtime.tools import TOOL_DEFINITIONS from band.testing import FakeAgentTools +from tests.adapters.dispatch import deliver_message from tests.mcpbackends import BackendStarts RawOpencodeEvent: TypeAlias = dict[str, Any] @@ -457,7 +458,8 @@ async def run_single_turn( content: str = "hello", ) -> None: await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content=content), tools_protocol(tools), OpencodeSessionState(), diff --git a/tests/adapters/opencode/test_approvals.py b/tests/adapters/opencode/test_approvals.py index 8c6e896be..fd119053a 100644 --- a/tests/adapters/opencode/test_approvals.py +++ b/tests/adapters/opencode/test_approvals.py @@ -30,6 +30,7 @@ OpencodeSessionState, ) from band.testing import FakeAgentTools, events_of_type +from tests.adapters.dispatch import deliver_message from tests.adapters.opencode.helpers import ( ASK_DEADLINE_S, AskFactory, @@ -700,7 +701,8 @@ async def test_manual_permission_reply_from_follow_up_message( await adapter.on_started("OpenCode Agent", "A coding agent") first_turn = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(content="Please continue"), tools_protocol(tools), OpencodeSessionState(), @@ -726,7 +728,8 @@ async def test_manual_permission_reply_from_follow_up_message( ) assert approval_requested["mentions"] - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="approve req-1"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -771,7 +774,8 @@ async def test_manual_question_reply_from_follow_up_message( await adapter.on_started("OpenCode Agent", "A coding agent") first_turn = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(content="Need an answer"), tools_protocol(tools), OpencodeSessionState(), @@ -797,7 +801,8 @@ async def test_manual_question_reply_from_follow_up_message( ) assert asked_question["mentions"] - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="Ship the adapter"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -844,7 +849,8 @@ async def test_auto_accept_approval_mode() -> None: tools = FakeAgentTools() await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -877,7 +883,8 @@ async def test_auto_decline_approval_mode() -> None: tools = FakeAgentTools() await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -902,7 +909,8 @@ async def test_auto_reject_question_mode() -> None: tools = FakeAgentTools() await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -950,7 +958,8 @@ async def test_a_turn_nobody_answers_expires_into_its_timeout_replies( tools = FakeAgentTools() await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -1177,7 +1186,8 @@ async def test_cleanup_with_pending_permission() -> None: await adapter.on_started("OpenCode Agent", "A coding agent") task = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -1221,7 +1231,8 @@ async def test_cleanup_with_pending_question() -> None: await adapter.on_started("OpenCode Agent", "A coding agent") task = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -1272,7 +1283,8 @@ async def test_always_permission_reply_from_follow_up_message( await adapter.on_started("OpenCode Agent", "A coding agent") first_turn = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(content="Please continue"), tools_protocol(tools), OpencodeSessionState(), @@ -1290,7 +1302,8 @@ async def test_always_permission_reply_from_follow_up_message( ) await asyncio.wait([first_turn]) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="always req-always"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -1477,7 +1490,8 @@ async def test_doom_loop_permission_still_relayed_in_manual_mode( await adapter.on_started("OpenCode Agent", "A coding agent") first_turn = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -1497,7 +1511,8 @@ async def test_doom_loop_permission_still_relayed_in_manual_mode( await asyncio.wait([first_turn]) assert fake_client.permission_replies == [] - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="approve perm-loop"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), diff --git a/tests/adapters/opencode/test_detached_turns.py b/tests/adapters/opencode/test_detached_turns.py index d77b30a77..b9495b899 100644 --- a/tests/adapters/opencode/test_detached_turns.py +++ b/tests/adapters/opencode/test_detached_turns.py @@ -7,9 +7,14 @@ from __future__ import annotations +import pytest + from band.adapters.opencode import OpencodeAdapter +from band.core.exceptions import InputNotAccepted +from band.integrations.opencode.types import OpencodeSessionState from band.runtime.tools import BandTool from band.testing import MISSING_REPLY_FAILURE, FakeAgentTools, failure_reports +from tests.adapters.dispatch import deliver_event from tests.adapters.opencode.helpers import ( BandMCPCalls, FakeOpencodeClient, @@ -39,12 +44,13 @@ def parked_turn_client(*, after_approval: list[ServerStep]) -> FakeOpencodeClien async def park_turn(adapter: OpencodeAdapter, tools: FakeAgentTools) -> None: """Run a turn's ``on_event`` until the permission ask releases it.""" await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_event(agent_input("run it", tools)) + await deliver_event(adapter, agent_input("run it", tools)) async def approve(adapter: OpencodeAdapter) -> FakeAgentTools: approver_tools = FakeAgentTools() - await adapter.on_event(agent_input(f"approve {PERMISSION_ID}", approver_tools)) + control = agent_input(f"approve {PERMISSION_ID}", approver_tools) + assert await adapter.try_handle_control(control.msg, control.tools) return approver_tools @@ -65,11 +71,10 @@ async def test_a_detached_turn_that_ends_with_nothing_is_reported_once( assert failure_reports(tools) == [MISSING_REPLY_FAILURE] -async def test_a_busy_message_during_a_detached_turn_is_settled( +async def test_a_busy_message_during_a_detached_turn_stays_unaccepted( make_adapter, tools, mcp_backend: BandMCPCalls ) -> None: - """The busy message's own turn is settled, and the detached turn's band - tool calls still resolve to the detached turn's tools.""" + """Unaccepted work leaves the detached turn's tool ownership intact.""" adapter = make_adapter( parked_turn_client( after_approval=[ @@ -83,12 +88,14 @@ async def test_a_busy_message_during_a_detached_turn_is_settled( busy_tools = FakeAgentTools() await park_turn(adapter, tools) - await adapter.on_event(agent_input("are you done?", busy_tools)) + with pytest.raises(InputNotAccepted): + await deliver_event(adapter, agent_input("are you done?", busy_tools)) await approve(adapter) await wait_for(lambda: tools.turn.replied) await adapter.on_cleanup("room-1") - assert busy_tools.turn.complete + assert not busy_tools.turn.complete + assert failure_reports(busy_tools) == [] assert busy_tools.messages_sent == [] assert tools.messages_sent[-1]["content"] == "Done." assert failure_reports(tools) == [] @@ -105,6 +112,33 @@ async def test_a_detached_turn_cancelled_by_cleanup_posts_nothing( assert failure_reports(tools) == [] +async def test_model_delivery_does_not_reclassify_a_newly_pending_question( + make_adapter, tools +) -> None: + client = FakeOpencodeClient( + prompt_event_sequences=[[event_question("sess-1", "q-1", "Which word?")]] + ) + adapter = make_adapter(client) + await adapter.on_started("OpenCode Agent", "A coding agent") + await deliver_event(adapter, agent_input("ask me which word", tools)) + incoming = agent_input("new ordinary work", FakeAgentTools()) + + with pytest.raises(InputNotAccepted): + await adapter.on_message( + incoming.msg, + incoming.tools, + OpencodeSessionState(), + None, + None, + is_session_bootstrap=False, + room_id=incoming.room_id, + ) + + assert client.question_replies == [] + assert not incoming.tools.turn.complete + await adapter.on_cleanup("room-1") + + async def test_a_rejected_question_hands_the_turn_back_to_the_model( make_adapter, tools ) -> None: @@ -124,9 +158,9 @@ async def test_a_rejected_question_hands_the_turn_back_to_the_model( ) ) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_event(agent_input("ask me which word", tools)) + await deliver_event(adapter, agent_input("ask me which word", tools)) - await adapter.on_event(agent_input("reject", FakeAgentTools())) + await deliver_event(adapter, agent_input("reject", FakeAgentTools())) await wait_for(lambda: tools.turn.replied) await adapter.on_cleanup("room-1") diff --git a/tests/adapters/opencode/test_lifecycle.py b/tests/adapters/opencode/test_lifecycle.py index 681df8c9e..f1ba0f5dd 100644 --- a/tests/adapters/opencode/test_lifecycle.py +++ b/tests/adapters/opencode/test_lifecycle.py @@ -7,7 +7,10 @@ from typing import Any from unittest.mock import patch +import pytest + from band.adapters.opencode import OpencodeAdapter +from band.core.exceptions import InputNotAccepted from band.core.types import ( Emit, TurnUsage, @@ -15,6 +18,7 @@ from band.integrations.opencode import OpencodePermissionRequest from band.integrations.opencode.types import OpencodeSessionState from band.testing import FakeAgentTools, events_of_type +from tests.adapters.dispatch import deliver_message from tests.adapters.opencode.helpers import ( FakeOpencodeClient, event_message_updated, @@ -67,7 +71,8 @@ async def racing_prompt(*args: Any, **kwargs: Any) -> None: await orig_prompt(*args, **kwargs) with patch.object(fake_client, "prompt_async", racing_prompt): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -217,7 +222,8 @@ async def test_cleanup_is_idempotent(make_adapter, tools) -> None: adapter = make_adapter(fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -267,7 +273,8 @@ async def test_cleanup_race_creates_a_fresh_client_for_the_next_room( with backends_created_by(fake_backend): await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(room_id="room-1"), tools_protocol(tools), OpencodeSessionState(), @@ -280,7 +287,8 @@ async def test_cleanup_race_creates_a_fresh_client_for_the_next_room( cleanup_task = asyncio.create_task(adapter.on_cleanup("room-1")) await wait_for(stop_started.is_set) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(room_id="room-2", content="next room"), tools_protocol(tools), OpencodeSessionState(), @@ -302,7 +310,7 @@ async def test_cleanup_race_creates_a_fresh_client_for_the_next_room( async def test_concurrent_message_rejected(make_adapter, tools) -> None: - """Sending a second message while a turn is active returns an error.""" + """An active turn definitively rejects another ordinary provider request.""" # First prompt never completes (no session.idle event) fake_client = FakeOpencodeClient( prompt_event_sequences=[ @@ -314,7 +322,8 @@ async def test_concurrent_message_rejected(make_adapter, tools) -> None: await adapter.on_started("OpenCode Agent", "A coding agent") first_task = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(content="first"), tools_protocol(tools), OpencodeSessionState(), @@ -328,19 +337,19 @@ async def test_concurrent_message_rejected(make_adapter, tools) -> None: await wait_for(lambda: len(fake_client.prompt_calls) > 0) # Send second message while first is active - await adapter.on_message( - make_platform_message(content="second"), - tools_protocol(tools), - OpencodeSessionState(session_id="sess-1", room_id="room-1"), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) + with pytest.raises(InputNotAccepted): + await deliver_message( + adapter, + make_platform_message(content="second"), + tools_protocol(tools), + OpencodeSessionState(session_id="sess-1", room_id="room-1"), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id="room-1", + ) - # Second message should get rejected with "still processing" error - error_events = events_of_type(tools, "error") - assert any("still processing" in e["content"].lower() for e in error_events) + assert events_of_type(tools, "error") == [] assert len(fake_client.prompt_calls) == 1 # Clean up: cancel the first task @@ -383,7 +392,8 @@ async def prompt_async(self, session_id: str, **kwargs: Any) -> None: # pyrefly async def run_room(room_number: int, room_tools: FakeAgentTools) -> None: room_id = f"room-{room_number}" - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(room_id=room_id, content=f"hello room {room_number}"), tools_protocol(room_tools), OpencodeSessionState(), @@ -471,7 +481,8 @@ async def test_interrupting_a_turn_stops_the_reply_and_frees_the_room(tools) -> await adapter.on_started("OpenCode Agent", "A coding agent") interrupted = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(content="long task"), tools_protocol(tools), OpencodeSessionState(), @@ -491,7 +502,8 @@ async def test_interrupting_a_turn_stops_the_reply_and_frees_the_room(tools) -> assert tools.messages_sent == [], "posted the reply the user interrupted" # The room takes the next message instead of answering "still processing". - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="next"), tools_protocol(tools), OpencodeSessionState(), @@ -532,7 +544,8 @@ async def abort_session(self, session_id: str) -> None: await adapter.on_started("OpenCode Agent", "A coding agent") interrupted = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(content="long task"), tools_protocol(tools), OpencodeSessionState(), @@ -605,7 +618,8 @@ async def disconnect_mcp_server(self, name: str) -> None: await outgoing.disconnect_started.wait() successor = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(room_id="room-2", content="next room"), tools_protocol(tools), OpencodeSessionState(), @@ -640,7 +654,8 @@ async def test_shutdown_rechecks_for_room_arriving_after_cleanup_decision( with backends_created_by(backend): await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(room_id="room-1"), tools_protocol(tools), OpencodeSessionState(), diff --git a/tests/adapters/opencode/test_mcp_backend_replacement.py b/tests/adapters/opencode/test_mcp_backend_replacement.py index 8e851f93c..9ca0c5ac5 100644 --- a/tests/adapters/opencode/test_mcp_backend_replacement.py +++ b/tests/adapters/opencode/test_mcp_backend_replacement.py @@ -13,6 +13,7 @@ from band.integrations.opencode.types import OpencodeSessionState from band.runtime.tools import BandTool from band.testing import FakeAgentTools +from tests.adapters.dispatch import deliver_message from tests.adapters.opencode.helpers import ( FakeOpencodeClient, event_session_idle, @@ -42,7 +43,8 @@ async def test_a_crashed_server_is_re_registered_on_its_new_port( await adapter.on_started("OpenCode Agent", "A coding agent") async def send(content: str, *, bootstrap: bool) -> None: - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content=content), tools_protocol(tools), OpencodeSessionState(), diff --git a/tests/adapters/opencode/test_setup.py b/tests/adapters/opencode/test_setup.py index 61d73a96d..62274e64a 100644 --- a/tests/adapters/opencode/test_setup.py +++ b/tests/adapters/opencode/test_setup.py @@ -15,6 +15,7 @@ from band.integrations.opencode.types import OpencodeSessionState from band.runtime.tools import CONTACT_TOOL_NAMES, MEMORY_TOOL_NAMES from band.testing import FakeAgentTools, events_of_type +from tests.adapters.dispatch import deliver_message from tests.adapters.opencode.helpers import ( FakeOpencodeClient, event_message_updated, @@ -77,7 +78,8 @@ def agent_id(self) -> str: with backends_created_by(fake_backend): await adapter.on_started("Renameable Agent", "") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(IdentityTools()), OpencodeSessionState(), @@ -124,7 +126,8 @@ def echo_tool(input_data: EchoInput) -> str: with backends_created_by(fake_backend) as starts: await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -184,7 +187,8 @@ async def test_registers_shared_mcp_backend_on_startup() -> None: with backends_created_by(fake_backend): await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -228,7 +232,8 @@ async def test_mcp_registration_retries_until_connected() -> None: await adapter.on_started("OpenCode Agent", "A coding agent") for _ in range(3): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -257,7 +262,8 @@ async def test_bootstrap_creates_session_relays_text_and_persists_task( adapter = make_adapter(fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -292,7 +298,8 @@ async def test_reuses_persisted_session(make_adapter, tools) -> None: adapter = make_adapter(fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState( @@ -320,7 +327,8 @@ async def test_replaces_session_from_another_mcp_registration( adapter = make_adapter(fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState( @@ -354,7 +362,8 @@ async def test_missing_session_replays_history_into_new_prompt( adapter = make_adapter(fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="Continue from before"), tools_protocol(tools), OpencodeSessionState( @@ -391,7 +400,8 @@ async def test_capability_gating_controls_registered_tool_set( ), ) await bare_adapter.on_started("OpenCode Agent", "A coding agent") - await bare_adapter.on_message( + await deliver_message( + bare_adapter, make_platform_message(), tools_protocol(FakeAgentTools()), OpencodeSessionState(), @@ -409,7 +419,8 @@ async def test_capability_gating_controls_registered_tool_set( capabilities={Capability.MEMORY, Capability.CONTACTS}, ) await full_adapter.on_started("OpenCode Agent", "A coding agent") - await full_adapter.on_message( + await deliver_message( + full_adapter, make_platform_message(), tools_protocol(FakeAgentTools()), OpencodeSessionState(), diff --git a/tests/adapters/opencode/test_turns.py b/tests/adapters/opencode/test_turns.py index 678a0c111..8e6d79c89 100644 --- a/tests/adapters/opencode/test_turns.py +++ b/tests/adapters/opencode/test_turns.py @@ -13,6 +13,7 @@ APPROVAL_HANDLED_TEMPLATE, APPROVAL_REQUESTED_TEMPLATE, ) +from band.core.exceptions import InputNotAccepted from band.core.protocols import FAILURE_CODE_TIMEOUT, TurnResultAlreadyReported from band.core.types import ( Capability, @@ -24,10 +25,10 @@ from band.testing import ( MISSING_REPLY_FAILURE, FakeAgentTools, - events_of_type, failure_reports, reported_failures, ) +from tests.adapters.dispatch import deliver_event, deliver_message from tests.adapters.opencode.helpers import ( AnyHTTPStatusError, BandMCPCalls, @@ -118,7 +119,8 @@ async def test_prompt_submission_failure_does_not_leave_room_stuck( await adapter.on_started("OpenCode Agent", "A coding agent") with pytest.raises(httpx.HTTPStatusError): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="first try"), tools_protocol(tools), OpencodeSessionState(), @@ -128,7 +130,8 @@ async def test_prompt_submission_failure_does_not_leave_room_stuck( room_id="room-1", ) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="second try"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -201,7 +204,8 @@ async def test_reports_tool_events_when_enabled() -> None: tools = FakeAgentTools() await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -263,7 +267,8 @@ async def test_reports_tool_call_args_from_first_non_pending_frame( adapter = make_adapter(fake_client, emit=Emit.TOOL_CALLS) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -304,7 +309,8 @@ async def test_preserves_falsy_tool_result_outputs_when_reporting( tools = FakeAgentTools() await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -341,7 +347,8 @@ async def test_reports_is_error_on_a_failed_tool_result(make_adapter, tools) -> adapter = make_adapter(fake_client, emit=Emit.TOOL_CALLS) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -374,7 +381,7 @@ async def test_does_not_echo_user_text_parts_as_assistant_output( await adapter.on_started("OpenCode Agent", "A coding agent") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_event(agent_input("user prompt text", tools)) + await deliver_event(adapter, agent_input("user prompt text", tools)) assert tools.messages_sent == [] assert failure_reports(tools) == [MISSING_REPLY_FAILURE] @@ -397,7 +404,7 @@ async def test_a_reply_that_cannot_be_posted_is_reported_once( adapter = make_adapter(fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_event(agent_input("ping", tools)) + await deliver_event(adapter, agent_input("ping", tools)) assert [provider for provider, _ in failure_reports(tools)] == ["opencode"] @@ -434,7 +441,8 @@ async def test_ignores_reasoning_deltas_and_relays_final_text_only( adapter = make_adapter(fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="Reply with exactly: pong"), tools_protocol(tools), OpencodeSessionState(), @@ -454,7 +462,8 @@ async def test_session_error_emits_error_event(make_adapter, tools) -> None: adapter = make_adapter(fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -482,7 +491,8 @@ async def test_session_error_abandons_a_pending_permission(make_adapter, tools) await adapter.on_started("OpenCode Agent", "A coding agent") turn_task = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -526,7 +536,8 @@ async def test_session_idle_abandons_a_pending_permission(make_adapter, tools) - await adapter.on_started("OpenCode Agent", "A coding agent") turn_task = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -562,7 +573,8 @@ async def test_session_error_abandons_a_pending_question(make_adapter, tools) -> await adapter.on_started("OpenCode Agent", "A coding agent") turn_task = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -600,7 +612,8 @@ async def test_session_idle_abandons_a_pending_question(make_adapter, tools) -> await adapter.on_started("OpenCode Agent", "A coding agent") turn_task = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -658,7 +671,8 @@ async def reply_permission( await adapter.on_started("OpenCode Agent", "A coding agent") turn_task = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -675,7 +689,8 @@ async def reply_permission( await turn_task reply_task = asyncio.create_task( - adapter.on_message( + deliver_message( + adapter, make_platform_message(content="approve perm-1"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -708,7 +723,8 @@ async def test_stale_approval_id_survives_a_later_turn(make_adapter, tools) -> N adapter = make_adapter(fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="run it"), tools_protocol(tools), OpencodeSessionState(), @@ -717,7 +733,8 @@ async def test_stale_approval_id_survives_a_later_turn(make_adapter, tools) -> N is_session_bootstrap=True, room_id="room-1", ) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="approve perm-1"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -732,7 +749,8 @@ async def test_stale_approval_id_survives_a_later_turn(make_adapter, tools) -> N lambda: room_state.turn is not None and room_state.turn.turn_future.done() ) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="next turn"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -743,7 +761,8 @@ async def test_stale_approval_id_survives_a_later_turn(make_adapter, tools) -> N ) await wait_for(lambda: len(fake_client.prompt_calls) == 2) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="approve perm-1"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -777,7 +796,8 @@ async def test_new_turn_does_not_inherit_previous_human_wait( ) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="run it"), tools_protocol(tools), OpencodeSessionState(), @@ -787,7 +807,8 @@ async def test_new_turn_does_not_inherit_previous_human_wait( room_id="room-1", ) await asyncio.sleep(0.15) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="approve perm-1"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -802,7 +823,8 @@ async def test_new_turn_does_not_inherit_previous_human_wait( lambda: room_state.turn is not None and room_state.turn.turn_future.done() ) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="next turn"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -833,7 +855,8 @@ async def abort_session(self, session_id: str) -> None: adapter = make_adapter(fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -865,7 +888,8 @@ async def test_turn_timeout_aborts_session_and_emits_error() -> None: tools = FakeAgentTools() await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -916,7 +940,8 @@ async def test_emits_turn_usage_folding_reasoning_into_output( tools = FakeAgentTools() await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -955,7 +980,8 @@ async def test_malformed_events_do_not_kill_event_loop(make_adapter, tools) -> N adapter = make_adapter(fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(), @@ -1050,7 +1076,8 @@ async def test_manual_relay_releases_turn_when_mentionless_send_rejected( # No hang: on_message returns once the relay releases the turn wait, # even though the mention-less room post was rejected. await asyncio.wait_for( - adapter.on_message( + deliver_message( + adapter, make_platform_message(sender_id="", sender_name=""), tools_protocol(tools), OpencodeSessionState(), @@ -1089,7 +1116,8 @@ async def test_turn_completes_when_fallback_reply_send_rejected( await adapter.on_started("OpenCode Agent", "A coding agent") await asyncio.wait_for( - adapter.on_message( + deliver_message( + adapter, make_platform_message(sender_id="", sender_name=""), tools_protocol(tools), OpencodeSessionState(), @@ -1164,7 +1192,8 @@ async def test_approval_prompts_never_stand_in_for_the_models_reply( # Returns once the permission ask releases the turn wait. await run_single_turn(adapter, tools, content="run it") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="approve req-1"), tools_protocol(FakeAgentTools()), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -1205,7 +1234,8 @@ async def test_approval_reply_is_settled_and_leaves_the_turn_its_tools( approver_tools = FakeAgentTools() await run_single_turn(adapter, tools, content="run it") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="approve req-1"), tools_protocol(approver_tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -1221,12 +1251,10 @@ async def test_approval_reply_is_settled_and_leaves_the_turn_its_tools( assert tools.messages_sent[-1]["content"] == "Done." -async def test_busy_message_is_settled_and_leaves_the_turn_its_tools( +async def test_busy_message_is_unaccepted_and_leaves_the_turn_its_tools( make_adapter, tools, mcp_backend: BandMCPCalls ) -> None: - """A message arriving mid-turn only gets the busy notice, which settles - its own turn; the running turn's band tool calls still resolve to the - turn's tools.""" + """Unaccepted work cannot rebind the running turn's tools.""" fake_client = FakeOpencodeClient( prompt_event_sequences=[[event_message_updated("sess-1", "msg-1")]] ) @@ -1235,15 +1263,17 @@ async def test_busy_message_is_settled_and_leaves_the_turn_its_tools( running_turn = asyncio.create_task(run_single_turn(adapter, tools)) await wait_for(lambda: len(fake_client.prompt_calls) == 1) - await adapter.on_message( - make_platform_message(content="are you done?"), - tools_protocol(busy_tools), - OpencodeSessionState(session_id="sess-1", room_id="room-1"), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) + with pytest.raises(InputNotAccepted): + await deliver_message( + adapter, + make_platform_message(content="are you done?"), + tools_protocol(busy_tools), + OpencodeSessionState(session_id="sess-1", room_id="room-1"), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id="room-1", + ) await fake_client.push_event( mcp_backend.band_tool_call( BandTool.SEND_MESSAGE, {"content": "Done.", "mentions": ["@alice"]} @@ -1252,10 +1282,8 @@ async def test_busy_message_is_settled_and_leaves_the_turn_its_tools( await fake_client.push_event(event_session_idle("sess-1")) await running_turn - assert busy_tools.turn.complete - assert [e["content"] for e in events_of_type(busy_tools, "error")] == [ - "OpenCode is still processing the previous request in this room." - ] + assert not busy_tools.turn.complete + assert busy_tools.events_sent == [] assert tools.chat == ["Done."] @@ -1345,7 +1373,8 @@ async def test_manual_approval_pauses_the_turn_timeout(make_adapter, tools) -> N await adapter.on_started("OpenCode Agent", "A coding agent") # Returns once the permission ask releases the turn wait. - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="run it"), tools_protocol(tools), OpencodeSessionState(), @@ -1360,7 +1389,8 @@ async def test_manual_approval_pauses_the_turn_timeout(make_adapter, tools) -> N assert fake_client.aborted_sessions == [] # The human approves via a room reply; the held turn completes and delivers. - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="approve req-1"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -1420,7 +1450,8 @@ async def finish() -> None: adapter = make_adapter(fake_client, config=config) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="run it"), tools_protocol(tools), OpencodeSessionState(), @@ -1431,7 +1462,8 @@ async def finish() -> None: ) await asyncio.sleep(0.25) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="approve req-1"), tools_protocol(tools), OpencodeSessionState(session_id="sess-1", room_id="room-1"), @@ -1465,7 +1497,8 @@ async def test_session_saved_before_registration_tracking_is_reused(tools) -> No adapter = OpencodeAdapter(client_factory=lambda _config: fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools_protocol(tools), OpencodeSessionState(session_id="sess-legacy", room_id="room-1"), diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 6ce9d4050..82ab6e398 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -24,7 +24,6 @@ _THOUGHT_ITEM_TYPES, _TOOL_ITEM_TYPES, NO_APPROVALS_TO_RESOLVE_MESSAGE, - TURN_IN_PROGRESS_MESSAGE, ApprovalDecision, CodexAdapter, CodexAdapterConfig, @@ -33,6 +32,7 @@ ) from band.client.streaming import ControlMode from band.core.defaultmodels import OPENAI_MODEL +from band.core.exceptions import InputNotAccepted from band.core.protocols import ( GENERIC_PROVIDER_FAILURE_MESSAGE, TurnResultAlreadyReported, @@ -77,6 +77,7 @@ tool_call_request, turn_completed, ) +from tests.adapters.dispatch import deliver_event, deliver_message from tests.framework_conformance.turnprobes import undeclared from tests.paths import host_absolute_path @@ -231,7 +232,8 @@ async def send_bootstrap( tools: FakeAgentTools | None = None, ) -> None: """Deliver a room's session-bootstrap message to ``adapter``.""" - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(room_id=room_id), tools if tools is not None else ToolSchemaFakeTools(), CodexSessionState(), @@ -271,7 +273,8 @@ def turn(self) -> asyncio.Task[None]: async def send(self, content: str) -> None: self.deliveries.append(tools := ToolSchemaFakeTools()) - await self.adapter.on_event( + await deliver_event( + self.adapter, AgentInput( msg=make_platform_message(room_id=ROOM_ID, content=content), tools=tools, @@ -280,7 +283,7 @@ async def send(self, content: str) -> None: contacts_msg=None, is_session_bootstrap=len(self.deliveries) == 1, room_id=ROOM_ID, - ) + ), ) async def settled(self) -> None: @@ -334,7 +337,8 @@ async def test_bootstrap_starts_thread_without_relaying_native_deltas(self) -> N tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -368,7 +372,8 @@ async def test_assistant_text_as_thought_posts_no_reply(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -412,7 +417,8 @@ async def test_system_prompt_retry_after_turn_start_failure(self) -> None: await adapter.on_started("Codex Agent", "A coding agent") with pytest.raises(CodexJsonRpcError, match="not available"): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(room_id="room-1", content="first try"), tools, CodexSessionState(), @@ -423,7 +429,8 @@ async def test_system_prompt_retry_after_turn_start_failure(self) -> None: ) assert "room-1" not in adapter._prompt_injected_rooms - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(room_id="room-1", content="second try"), tools, CodexSessionState(), @@ -456,7 +463,8 @@ async def test_tool_call_request_is_dispatched_and_responded(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -523,7 +531,8 @@ async def execute_tool_call_structured( tools = SendMessageFailureTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -553,7 +562,8 @@ async def test_resume_failure_falls_back_to_thread_start(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(thread_id="thr-old", room_id="room-1"), @@ -584,7 +594,8 @@ async def test_approval_request_auto_decline(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -628,7 +639,8 @@ async def send_notice( tools = FailingNotifyTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -668,7 +680,8 @@ async def send_notice( tools = FailingNotifyTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -709,7 +722,8 @@ async def test_cleanup_closes_client_when_last_room_removed(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(room_id="room-1"), tools, CodexSessionState(), @@ -731,7 +745,8 @@ async def test_cleanup_idempotent(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(room_id="room-1"), tools, CodexSessionState(), @@ -762,7 +777,8 @@ async def test_cleanup_multi_room_closes_each_room_client(self) -> None: await adapter.on_started("Codex Agent", "A coding agent") for room_id in ("room-1", "room-2"): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(room_id=room_id), tools, CodexSessionState(), @@ -797,7 +813,8 @@ async def test_forwards_raw_codex_task_events(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -843,7 +860,8 @@ async def test_can_disable_synthetic_turn_task_markers(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -882,7 +900,8 @@ async def test_raw_task_event_without_explicit_task_id_does_not_emit_uuid( tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -910,7 +929,8 @@ async def test_status_command_returns_state_without_starting_turn(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="@thenvoi/ar-2-darter /status"), tools, CodexSessionState(), @@ -934,7 +954,8 @@ async def test_model_command_sets_override_without_starting_turn(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/model gpt-5.5-codex"), tools, CodexSessionState(), @@ -960,7 +981,8 @@ async def test_models_alias_lists_models_without_starting_turn(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/models list"), tools, CodexSessionState(), @@ -990,7 +1012,8 @@ async def test_reasoning_effort_passed_in_turn_overrides(self, effort: str) -> N ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="hello"), tools, CodexSessionState(), @@ -1009,7 +1032,8 @@ async def test_reasoning_effort_omitted_when_none(self) -> None: adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="hello"), tools, CodexSessionState(), @@ -1029,7 +1053,8 @@ async def test_reasoning_command_sets_effort(self, effort: str) -> None: adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content=f"/reasoning {effort}"), tools, CodexSessionState(), @@ -1053,7 +1078,8 @@ async def test_reasoning_command_lists_efforts_the_model_supports(self) -> None: ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/reasoning"), tools, CodexSessionState(), @@ -1093,7 +1119,8 @@ async def test_reasoning_command_checks_the_models_live_efforts( ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content=f"/reasoning {effort}"), tools, CodexSessionState(), @@ -1117,7 +1144,8 @@ async def test_reasoning_command_still_answers_when_model_list_fails(self) -> No ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/reasoning"), tools, CodexSessionState(), @@ -1140,7 +1168,8 @@ async def test_self_config_tools_registered_when_enabled(self) -> None: ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="hello"), tools, CodexSessionState(), @@ -1164,7 +1193,8 @@ async def test_self_config_tools_not_registered_when_disabled(self) -> None: ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="hello"), tools, CodexSessionState(), @@ -1198,7 +1228,8 @@ async def test_setmodel_tool_changes_model(self) -> None: ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="switch to o3"), tools, CodexSessionState(), @@ -1239,7 +1270,8 @@ async def test_setreasoning_tool_changes_effort(self, effort: str) -> None: ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="increase reasoning"), tools, CodexSessionState(), @@ -1262,7 +1294,8 @@ async def test_sandbox_alias_is_normalized_for_thread_and_turn(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1289,7 +1322,8 @@ async def test_external_sandbox_alias_uses_sandbox_policy(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1326,7 +1360,8 @@ async def test_transport_closed_event_aborts_turn( await adapter.on_started("Codex Agent", "A coding agent") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1358,7 +1393,8 @@ async def test_transport_closed_resets_client_state(self) -> None: await adapter.on_started("Codex Agent", "A coding agent") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1394,7 +1430,8 @@ async def test_transport_closed_clears_per_room_state(self) -> None: adapter._raw_history_by_room["room-1"] = [{"role": "user", "content": "hi"}] with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1436,7 +1473,8 @@ async def test_transport_closed_drains_token_usage_for_dead_threads( ) with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1471,7 +1509,8 @@ async def test_transport_closed_after_error_does_not_double_report(self) -> None await adapter.on_started("Codex Agent", "A coding agent") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1497,7 +1536,8 @@ async def test_turn_timeout_sends_interrupt_and_clean_error(self) -> None: await adapter.on_started("Codex Agent", "A coding agent") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1540,7 +1580,8 @@ async def test_turn_timeout_emits_failed_lifecycle_event(self) -> None: await adapter.on_started("Codex Agent", "A coding agent") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1588,7 +1629,8 @@ async def test_item_completed_text_overrides_accumulated_deltas(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1668,7 +1710,8 @@ async def calculate(inp: CalculatorInput) -> str: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1710,7 +1753,8 @@ async def test_execution_reporting_emits_tool_call_and_result_events(self) -> No tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1751,7 +1795,8 @@ async def test_send_room_file_tool_call_event_redacts_content(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1788,7 +1833,8 @@ async def test_execution_reporting_silenced_with_explicit_empty_emit(self) -> No tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1840,7 +1886,8 @@ async def fail_func(inp: FailInput) -> str: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1889,7 +1936,8 @@ async def test_execution_reporting_emitted_for_platform_output_tools( tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1951,7 +1999,8 @@ async def test_item_completed_mcpToolCall_send_room_file_redacts_content( tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -1996,7 +2045,8 @@ async def test_item_completed_commandExecution_emits_tool_events(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2050,7 +2100,8 @@ async def test_item_completed_fileChange_emits_tool_events(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2096,7 +2147,8 @@ async def test_item_completed_fileChange_missing_changes_is_safe(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2136,7 +2188,8 @@ async def test_item_completed_imageView_emits_tool_events(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2181,7 +2234,8 @@ async def test_item_completed_collabAgentToolCall_emits_tool_events(self) -> Non tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2228,7 +2282,8 @@ async def test_item_completed_collabAgentToolCall_non_text_list_result_preserves tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2269,7 +2324,8 @@ async def test_item_completed_mcpToolCall_emits_tool_events(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2325,7 +2381,8 @@ async def test_item_completed_mcpToolCall_non_text_list_result_preserves_data( tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2393,7 +2450,8 @@ async def test_item_completed_reasoning_emits_thought(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2434,7 +2492,8 @@ async def test_item_completed_dict_summary_text_emits_thought(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2492,7 +2551,8 @@ async def test_item_completed_empty_reasoning_summary_skips_thought(self) -> Non tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2526,7 +2586,8 @@ async def test_item_completed_empty_plan_text_skips_thought(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2573,7 +2634,8 @@ async def test_item_completed_skipped_when_reporting_disabled(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2625,7 +2687,8 @@ async def test_completed_agent_message_is_emitted_as_a_thought(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2667,7 +2730,8 @@ async def test_item_completed_webSearch_emits_tool_events(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2707,7 +2771,8 @@ async def test_item_completed_webSearch_non_text_list_action_preserves_data( tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2746,7 +2811,8 @@ async def test_item_completed_metadata_includes_codex_ids(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -2811,7 +2877,7 @@ async def test_history_injected_on_resume_failure(self) -> None: is_session_bootstrap=True, room_id="room-1", ) - await adapter.on_event(inp) + await deliver_event(adapter, inp) turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] turn_input = turn_start["input"] @@ -2866,7 +2932,7 @@ async def test_history_not_injected_on_successful_resume(self) -> None: is_session_bootstrap=True, room_id="room-1", ) - await adapter.on_event(inp) + await deliver_event(adapter, inp) turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] turn_input = turn_start["input"] @@ -2909,7 +2975,7 @@ async def test_history_not_injected_when_disabled(self) -> None: is_session_bootstrap=True, room_id="room-1", ) - await adapter.on_event(inp) + await deliver_event(adapter, inp) turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] turn_input = turn_start["input"] @@ -2976,7 +3042,7 @@ async def test_history_filters_non_text_messages(self) -> None: is_session_bootstrap=True, room_id="room-1", ) - await adapter.on_event(inp) + await deliver_event(adapter, inp) turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] turn_input = turn_start["input"] @@ -3038,7 +3104,7 @@ async def test_history_respects_max_messages(self) -> None: is_session_bootstrap=True, room_id="room-1", ) - await adapter.on_event(inp) + await deliver_event(adapter, inp) turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] turn_input = turn_start["input"] @@ -3088,7 +3154,7 @@ async def test_history_cleared_after_injection(self) -> None: is_session_bootstrap=True, room_id="room-1", ) - await adapter.on_event(inp) + await deliver_event(adapter, inp) # After injection, stashed data should be cleaned up assert "room-1" not in adapter._raw_history_by_room @@ -3116,7 +3182,8 @@ async def test_auto_selected_model_error_propagates_without_retry(self) -> None: msg = make_platform_message(room_id="room-1", content="hello") with pytest.raises(CodexJsonRpcError, match="not available"): - await adapter.on_message( + await deliver_message( + adapter, msg, tools, CodexSessionState(), @@ -3172,7 +3239,8 @@ async def test_explicit_model_error_propagates_without_fallback(self) -> None: msg = make_platform_message(room_id="room-1", content="hello") with pytest.raises(CodexJsonRpcError, match="not available"): - await adapter.on_message( + await deliver_message( + adapter, msg, tools, CodexSessionState(), @@ -3230,7 +3298,8 @@ async def test_non_model_error_propagates(self) -> None: msg = make_platform_message(room_id="room-1", content="hello") with pytest.raises(CodexJsonRpcError, match="overloaded"): - await adapter.on_message( + await deliver_message( + adapter, msg, tools, CodexSessionState(), @@ -3288,7 +3357,8 @@ async def test_codex_error_emits_event_unconditionally(self) -> None: msg = make_platform_message(room_id="room-1", content="do something") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, msg, tools, CodexSessionState(), @@ -3372,7 +3442,8 @@ async def execute_tool_call_structured( tools = ValidationErrorTools() await adapter.on_started("Bot", "desc") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -3424,7 +3495,8 @@ async def test_structured_error_from_error_event(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -3469,7 +3541,8 @@ async def test_structured_error_from_failed_turn(self) -> None: await adapter.on_started("Agent", "A coding agent") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -3499,7 +3572,8 @@ async def test_structured_error_from_failed_turn_with_no_error_key(self) -> None await adapter.on_started("Agent", "A coding agent") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -3528,7 +3602,8 @@ async def test_generic_exception_reports_and_propagates(self) -> None: await adapter.on_started("Agent", "A coding agent") with pytest.raises(RuntimeError, match="transport hiccup"): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -3587,7 +3662,8 @@ async def test_approval_audit_trail_emitted(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -3613,7 +3689,8 @@ async def test_sandbox_command_changes_mode(self) -> None: adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/sandbox read-only"), tools, CodexSessionState(), @@ -3647,7 +3724,8 @@ async def test_sandbox_danger_full_access_requires_confirm_flag(self) -> None: adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/sandbox danger-full-access"), tools, CodexSessionState(), @@ -3672,7 +3750,8 @@ async def test_sandbox_escalation_to_danger_full_access_logs_warning( tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") with caplog.at_level(logging.WARNING, logger="band.adapters.codex"): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/sandbox danger-full-access --confirm"), tools, CodexSessionState(), @@ -3695,7 +3774,8 @@ async def test_sandbox_command_rejects_invalid_mode(self) -> None: adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/sandbox invalid-mode"), tools, CodexSessionState(), @@ -3716,7 +3796,8 @@ async def test_permissions_command_shows_state(self) -> None: adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/permissions"), tools, CodexSessionState(), @@ -3761,7 +3842,8 @@ async def test_plan_steps_forwarded(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -3806,7 +3888,8 @@ async def test_plan_steps_not_forwarded_when_disabled(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -3836,7 +3919,8 @@ async def test_turn_lifecycle_events_emitted(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -3870,7 +3954,8 @@ async def test_threads_command_lists_mappings(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -3881,7 +3966,8 @@ async def test_threads_command_lists_mappings(self) -> None: ) # Now run /threads - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/threads"), tools, CodexSessionState(), @@ -3911,7 +3997,8 @@ async def test_thread_archive_clears_mapping(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -3922,7 +4009,8 @@ async def test_thread_archive_clears_mapping(self) -> None: ) assert "room-1" in adapter._room_threads - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/thread archive"), tools, CodexSessionState(), @@ -3958,7 +4046,8 @@ async def test_reasoning_delta_streamed_as_thought(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -3994,7 +4083,8 @@ async def test_reasoning_delta_ignored_when_disabled(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4026,7 +4116,8 @@ async def test_plan_delta_streamed_as_thought(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4067,7 +4158,8 @@ async def test_commentary_phase_streamed_as_thought(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4110,7 +4202,8 @@ async def test_streamed_commentary_is_not_replayed_on_completion( tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4147,7 +4240,8 @@ async def test_unstreamed_completed_messages_are_separate_thoughts( tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4190,7 +4284,8 @@ async def test_diff_event_forwarded(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4228,7 +4323,8 @@ async def test_diff_event_requires_task_events_emit(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4269,7 +4365,8 @@ async def test_token_usage_tracked_and_emitted(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4312,7 +4409,8 @@ async def test_token_usage_ignored_when_disabled(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4358,7 +4456,8 @@ async def test_usage_command_shows_token_usage(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4369,7 +4468,8 @@ async def test_usage_command_shows_token_usage(self) -> None: ) # Now run /usage - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/usage"), tools, CodexSessionState(), @@ -4592,7 +4692,8 @@ async def test_session_auto_approves_matching_command_binary(self) -> None: [("commandExecution:npm install", None)] ) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="install deps"), tools, CodexSessionState(), @@ -4632,7 +4733,8 @@ async def test_session_does_not_auto_approve_different_command(self) -> None: [("commandExecution:npm install", None)] ) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="publish package"), tools, CodexSessionState(), @@ -4672,7 +4774,8 @@ async def test_session_binary_granularity_approves_same_binary(self) -> None: [("commandExecution:npm", None)] ) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="install deps"), tools, CodexSessionState(), @@ -4712,7 +4815,8 @@ async def test_on_cleanup_removes_per_room_token_usage(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4804,7 +4908,8 @@ async def test_sandbox_command_blocked_when_sandbox_policy_set(self) -> None: ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/sandbox workspace-write"), tools, CodexSessionState(), @@ -4828,7 +4933,8 @@ async def test_thread_archive_clears_raw_history(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4842,7 +4948,8 @@ async def test_thread_archive_clears_raw_history(self) -> None: adapter._raw_history_by_room["room-1"] = [{"role": "user", "content": "hi"}] adapter._needs_history_injection.add("room-1") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/thread archive"), tools, CodexSessionState(), @@ -4897,7 +5004,8 @@ async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4926,7 +5034,8 @@ async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: await adapter.on_started("Agent", "A coding agent") with pytest.raises(CodexJsonRpcError, match="model unavailable"): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -4960,7 +5069,8 @@ async def test_failed_turn_emits_terminal_lifecycle_event(self) -> None: await adapter.on_started("Agent", "A coding agent") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -5010,7 +5120,8 @@ async def test_session_auto_approval_sends_accept_for_session(self) -> None: [("commandExecution:npm install", None)] ) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="install deps"), tools, CodexSessionState(), @@ -5072,7 +5183,8 @@ async def test_turn_started_lifecycle_event_emitted(self) -> None: ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="fix the login bug"), tools, CodexSessionState(), @@ -5111,7 +5223,8 @@ async def test_context_compaction_event_emitted(self) -> None: ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -5145,7 +5258,8 @@ async def test_context_compaction_ignored_when_disabled(self) -> None: ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -5443,7 +5557,8 @@ async def test_approve_session_refused_for_fileChange_without_paths(self) -> Non tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -5458,7 +5573,8 @@ async def test_approve_session_refused_for_fileChange_without_paths(self) -> Non # so /approve-session reports no pending approvals rather than # storing an empty session key. tools.messages_sent.clear() - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/approve-session 88"), tools, CodexSessionState(), @@ -5505,9 +5621,16 @@ async def test_the_reply_delivered_after_the_asking_message_resolves_it( ) await room.send("run tests") - await room.send(f"/{CodexCommand.APPROVE} req-10") + control_tools = ToolSchemaFakeTools() + assert await room.adapter.try_handle_control( + make_platform_message( + room_id=ROOM_ID, content=f"/{CodexCommand.APPROVE} req-10" + ), + control_tools, + ) await room.settled() + assert control_tools.turn.complete assert room.client.responses == [(10, {"decision": "accept"})] @pytest.mark.asyncio @@ -5519,9 +5642,10 @@ async def test_a_request_while_a_turn_awaits_a_human_is_turned_away( ) await room.send("run tests") - await room.send("and lint too") + with pytest.raises(InputNotAccepted): + await room.send("and lint too") - assert room.chat[-1] == TURN_IN_PROGRESS_MESSAGE + assert not room.deliveries[-1].turn.complete methods = room.client.request_methods assert methods.count(CodexRequestMethod.TURN_START) == 1 @@ -5856,7 +5980,8 @@ async def test_thread_info_with_no_mapping(self) -> None: adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/thread info"), tools, CodexSessionState(), @@ -5891,7 +6016,8 @@ async def test_thread_info_includes_thread_and_usage(self) -> None: ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -5900,7 +6026,8 @@ async def test_thread_info_includes_thread_and_usage(self) -> None: is_session_bootstrap=True, room_id="room-1", ) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/thread info"), tools, CodexSessionState(), @@ -5924,7 +6051,8 @@ async def test_permissions_reflects_sandbox_override(self) -> None: adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/sandbox read-only"), tools, CodexSessionState(), @@ -5933,7 +6061,8 @@ async def test_permissions_reflects_sandbox_override(self) -> None: is_session_bootstrap=True, room_id="room-1", ) - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/permissions"), tools, CodexSessionState(), @@ -5974,7 +6103,8 @@ async def send_notice( caplog.at_level(logging.ERROR, logger="band.core.delivery"), pytest.raises(RuntimeError, match="platform rejected the message"), ): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/help"), tools, CodexSessionState(), @@ -6013,7 +6143,8 @@ async def send_notice( caplog.at_level(logging.ERROR, logger="band.core.delivery"), pytest.raises(RuntimeError, match="platform rejected the message"), ): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(content="/approvals"), tools, CodexSessionState(), @@ -6045,7 +6176,8 @@ async def test_error_event_with_non_dict_error_field(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -6083,7 +6215,8 @@ async def test_failed_turn_after_error_notification_reports_once(self) -> None: await adapter.on_started("Agent", "A coding agent") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -6120,7 +6253,8 @@ async def test_failed_turn_with_falsy_scalar_error_uses_clean_fallback( await adapter.on_started("Agent", "A coding agent") with pytest.raises(TurnResultAlreadyReported): - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -6148,7 +6282,8 @@ async def test_turn_completed_without_items_key(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -6175,7 +6310,8 @@ async def test_turn_plan_updated_with_garbage_steps(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -6201,7 +6337,8 @@ async def test_no_lifecycle_events_when_disabled(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -6363,7 +6500,8 @@ async def test_multibyte_diff_respects_byte_budget(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), tools, CodexSessionState(), @@ -6831,7 +6969,8 @@ async def test_messages_during_a_released_turn_never_take_over_its_reply( ) await room.send("run tests") - await room.send("and lint too") + with pytest.raises(InputNotAccepted): + await room.send("and lint too") await room.send("/approve req-10") await room.settled() @@ -6841,8 +6980,9 @@ async def test_messages_during_a_released_turn_never_take_over_its_reply( "Tests pass.", ] assert failure_reports(turn) == [MISSING_REPLY_FAILURE] - assert busy.chat == [TURN_IN_PROGRESS_MESSAGE] - assert turn.turn.complete and busy.turn.complete and approval.turn.complete + assert busy.chat == [] + assert not busy.turn.complete + assert turn.turn.complete and approval.turn.complete assert failure_reports(busy) == failure_reports(approval) == [] @pytest.mark.asyncio @@ -7010,7 +7150,8 @@ async def test_restored_codex_thread_receives_the_mandatory_transport_contract() client, config=CodexAdapterConfig(system_prompt="custom") ) await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( + await deliver_message( + adapter, make_platform_message(), ToolSchemaFakeTools(), CodexSessionState(thread_id="restored"), diff --git a/tests/adapters/test_cursor_acp_adapter.py b/tests/adapters/test_cursor_acp_adapter.py index 944ccaa2f..ceac1c76c 100644 --- a/tests/adapters/test_cursor_acp_adapter.py +++ b/tests/adapters/test_cursor_acp_adapter.py @@ -1236,9 +1236,8 @@ async def test_a_turn_still_running_at_cleanup_leaves_no_todos() -> None: @pytest.mark.asyncio async def test_a_cleanup_cancelled_mid_stop_still_releases_the_session() -> None: - """The room may rejoin and restore its session while the old runtime is - still exiting, so the bootstrap mark goes before the stop; the todos go - after it, even when the stop is cancelled.""" + """A rejoined room gets fresh context while its old runtime is exiting; + cancellation still releases the retired session's todo state.""" agent = FakeACPAgent().will_update_cursor_todos("ship it").will_say("ok") exiting = agent.exits_slowly() adapter = CursorACPAdapter(CursorACPAdapterConfig(command="fake-agent")) @@ -1249,7 +1248,10 @@ async def test_a_cleanup_cancelled_mid_stop_still_releases_the_session() -> None cleanup = asyncio.create_task(adapter.on_cleanup("room-1")) await exiting.received.wait() - assert adapter._claim_session_bootstrap(released) + await session.send("rejoin the room", room="room-1") + assert session.session_id("room-1") != released + assert "[System Context]" in agent.prompt_texts()[-1] cleanup.cancel() await asyncio.gather(cleanup, return_exceptions=True) assert released not in adapter._cursor_profile._todos_by_session + exiting.release.set() diff --git a/tests/core/test_simple_adapter.py b/tests/core/test_simple_adapter.py index e1b606907..0e2d4b52c 100644 --- a/tests/core/test_simple_adapter.py +++ b/tests/core/test_simple_adapter.py @@ -5,13 +5,13 @@ import pytest -from band.core.exceptions import BandConfigError +from band.core.exceptions import BandConfigError, InputNotAccepted from band.core.model_catalog import ModelSelection from band.core.protocols import FrameworkAdapter, HistoryConverter from band.core.simple_adapter import SimpleAdapter from band.core.types import AgentInput, HistoryProvider, PlatformMessage from band.logging_config import TRACE_CONTEXT -from band.testing import FakeAgentTools +from band.testing import FakeAgentTools, failure_reports from tests.logsupport import fake_traceparent @@ -399,3 +399,15 @@ async def test_handles_multiple_events(self): assert adapter.calls[0]["msg"].content == "First" assert adapter.calls[1]["msg"].content == "Second" assert adapter.calls[2]["msg"].content == "Third" + + +async def test_unaccepted_input_is_not_judged_or_reported() -> None: + class OccupiedAdapter(RecordingAdapter): + async def on_message(self, *args: Any, **kwargs: Any) -> None: + raise InputNotAccepted("occupied") + + inp = make_agent_input() + with pytest.raises(InputNotAccepted): + await OccupiedAdapter().on_event(inp) + assert not inp.tools.turn.judged + assert failure_reports(inp.tools) == [] diff --git a/tests/e2e/baseline/expected-cells.json b/tests/e2e/baseline/expected-cells.json index f7c2de89e..2105fea2a 100644 --- a/tests/e2e/baseline/expected-cells.json +++ b/tests/e2e/baseline/expected-cells.json @@ -995,6 +995,33 @@ "status": "pass", "reason": null }, + { + "test": "tests/e2e/baseline/smoke/behavior/test_approvals.py::test_pending_work_preserves_order_without_hiding_an_approval", + "adapter": "claude_sdk", + "lanes": [ + "core" + ], + "status": "pass", + "reason": null + }, + { + "test": "tests/e2e/baseline/smoke/behavior/test_approvals.py::test_pending_work_preserves_order_without_hiding_an_approval", + "adapter": "codex", + "lanes": [ + "backends" + ], + "status": "pass", + "reason": null + }, + { + "test": "tests/e2e/baseline/smoke/behavior/test_approvals.py::test_pending_work_preserves_order_without_hiding_an_approval", + "adapter": "opencode", + "lanes": [ + "backends" + ], + "status": "pass", + "reason": null + }, { "test": "tests/e2e/baseline/smoke/behavior/test_approvals.py::test_the_room_reply_decides_whether_the_gated_command_runs[approve-claude_sdk]", "adapter": "suite", diff --git a/tests/e2e/baseline/smoke/behavior/test_approvals.py b/tests/e2e/baseline/smoke/behavior/test_approvals.py index 9d1314a15..e419d7fd0 100644 --- a/tests/e2e/baseline/smoke/behavior/test_approvals.py +++ b/tests/e2e/baseline/smoke/behavior/test_approvals.py @@ -36,6 +36,7 @@ import pytest import band.adapters.opencode +from band.client.streaming import DeliveryStatus from band.core.simple_adapter import SimpleAdapter from tests.e2e.baseline.agents import Adapter, per_adapter from tests.e2e.baseline.requires import require_dep @@ -55,7 +56,7 @@ repeat_request, written_lines, ) -from tests.e2e.baseline.smoke.samples.sample_agents import unique_marker +from tests.e2e.baseline.smoke.samples.sample_agents import liveness_probe, unique_marker from tests.e2e.baseline.timeouts import SlowTurnBudget, slow_turn_budget from tests.e2e.baseline.toolkit.capture import CaptureFactory from tests.e2e.baseline.toolkit.logs import sdk_logs_at @@ -84,6 +85,7 @@ def readback_commands(target: Path) -> frozenset[str]: # Two requests and the closing reply; or a request, a refusal and the closing reply. THREE_BARRIERS = slow_turn_budget(TURN_BUDGET_S, barriers=3) # Short enough to expire promptly, long enough that the request is captured first. +QUEUED_WORK_BUDGET = slow_turn_budget(TURN_BUDGET_S, barriers=5) EXPIRING_WAIT_S = 10.0 # Outlasts every barrier of the longest flow (and stays under Cursor's default turn # timeout, which it must). @@ -240,6 +242,70 @@ async def test_each_of_two_gated_commands_is_decided_on_its_own( assert written_lines(target) == [marker] +@per_adapter(Adapter.CLAUDE_SDK, Adapter.CODEX, Adapter.OPENCODE) +@pytest.mark.timeout(extra=QUEUED_WORK_BUDGET.extra_s) +@pytest.mark.asyncio(loop_scope="session") +async def test_pending_work_preserves_order_without_hiding_an_approval( + cell: AdapterCell, + user_ops: UserOps, + reply_capture: CaptureFactory, +) -> None: + """A's approval remains reachable while B and D wait for their own turns.""" + marker = unique_marker("approved") + done, first, second = ( + unique_marker(name) for name in ("closed", "first", "second") + ) + async with approval_room( + cell, + user_ops, + reply_capture, + label="pending-work", + budget=QUEUED_WORK_BUDGET, + wait_timeout_s=QUEUED_WORK_BUDGET.deadline_s * 2, + ) as (room, workdir): + target = workdir / "approval.txt" + start = await room.say(command_request(marker, target, done=done)) + [request] = await room.requests(1, since=start) + first_start, first_id = await room.post(liveness_probe(first)) + await room.capture.wait_for_delivery( + first_id, + room.agent.id, + until={DeliveryStatus.PROCESSING}, + deadline_s=QUEUED_WORK_BUDGET.deadline_s, + ) + second_start, second_id = await room.post(liveness_probe(second)) + await room.decide(Outcome.APPROVE, request) + await room.closed( + room.dialect.notice(Outcome.APPROVE, request), + since=start, + closing_reply=done, + allowed_followup_commands=readback_commands(target), + ) + await room.shown(first, since=first_start) + first_replies = await room.capture.wait_for_reply( + first_id, + room.agent.id, + since=first_start, + deadline_s=QUEUED_WORK_BUDGET.deadline_s, + ) + first_replies.assert_contains_any([first]) + await room.shown(second, since=second_start) + second_replies = await room.capture.wait_for_reply( + second_id, + room.agent.id, + since=second_start, + deadline_s=QUEUED_WORK_BUDGET.deadline_s, + ) + second_replies.assert_contains_any([second]) + said = room.said_since(start) + first_position = next(i for i, content in enumerate(said) if first in content) + second_position = next(i for i, content in enumerate(said) if second in content) + assert first_position < second_position, ( + "newer ordinary work overtook pending work" + ) + assert written_lines(target) == [marker] + + @per_adapter(*REFUSING) @pytest.mark.timeout(extra=THREE_BARRIERS.extra_s) @pytest.mark.asyncio(loop_scope="session") diff --git a/tests/framework_conformance/turnprobes/claudesdk.py b/tests/framework_conformance/turnprobes/claudesdk.py index da2a58819..baac06471 100644 --- a/tests/framework_conformance/turnprobes/claudesdk.py +++ b/tests/framework_conformance/turnprobes/claudesdk.py @@ -69,9 +69,8 @@ async def ask_claude_sdk_status(tools: FakeAgentTools) -> None: ) await adapter.on_started("Agent", "An agent under test") try: - await adapter.on_event( - turn_input(tools, user_message(f"/{ClaudeSDKCommand.STATUS}")) - ) + control = user_message(f"/{ClaudeSDKCommand.STATUS}") + assert await adapter.try_handle_control(control, tools) finally: await adapter.cleanup_all() diff --git a/tests/framework_conformance/turnprobes/opencode.py b/tests/framework_conformance/turnprobes/opencode.py index b66a3da6c..1c58b5470 100644 --- a/tests/framework_conformance/turnprobes/opencode.py +++ b/tests/framework_conformance/turnprobes/opencode.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import contextlib from collections.abc import AsyncIterator @@ -12,6 +11,7 @@ BandMCPCalls, FakeOpencodeClient, event_message_updated, + event_permission, event_session_idle, event_text_part, wait_for, @@ -63,23 +63,26 @@ async def run_opencode(script: TurnScript, tools: FakeAgentTools) -> None: async def settle_opencode(tools: FakeAgentTools) -> None: - """A message arriving mid-turn gets only the busy notice.""" - + """An approval control completes independently of its parked model turn.""" client = FakeOpencodeClient( - prompt_event_sequences=[[event_message_updated(SESSION_ID, MESSAGE_ID)]] + prompt_event_sequences=[[event_permission(SESSION_ID, "req-1")]], + reply_permission_events={ + "req-1": [ + event_message_updated(SESSION_ID, MESSAGE_ID), + event_text_part(SESSION_ID, MESSAGE_ID, "Done."), + event_session_idle(SESSION_ID), + ] + }, ) + original_tools = turn_tools() with backends_created_by(): async with started_adapter(client) as adapter: - running = asyncio.create_task(adapter.on_event(turn_input(turn_tools()))) - try: - await wait_for(lambda: bool(client.prompt_calls)) - await adapter.on_event( - turn_input(tools, user_message("@agent are you done?")) - ) - finally: - running.cancel() - with contextlib.suppress(asyncio.CancelledError): - await running + await adapter.on_event(turn_input(original_tools)) + assert await adapter.try_handle_control( + user_message("@agent approve req-1"), tools + ) + await wait_for(lambda: original_tools.turn.replied) + assert len(client.prompt_calls) == 1 PROBES: dict[str, TurnOutcomeProbe] = { diff --git a/tests/integrations/acp/test_client_adapter.py b/tests/integrations/acp/test_client_adapter.py index 95f6e6c80..14cee3051 100644 --- a/tests/integrations/acp/test_client_adapter.py +++ b/tests/integrations/acp/test_client_adapter.py @@ -1689,7 +1689,6 @@ async def test_stop_closes_connection(self) -> None: adapter._room_to_session[_MOCK_ROOM] = RoomSession("session-123", band_url=None) adapter._room_tools[_MOCK_ROOM] = MagicMock() backend = await hold_backend(adapter._mcp) - adapter._bootstrapped_sessions.add("session-123") await adapter.stop() @@ -1701,7 +1700,6 @@ async def test_stop_closes_connection(self) -> None: assert adapter._room_to_session == {} assert adapter._room_tools == {} assert adapter._mcp.current is None - assert adapter._bootstrapped_sessions == set() @pytest.mark.asyncio async def test_stop_no_connection(self) -> None: diff --git a/tests/integrations/acp/test_client_adapter_behavior.py b/tests/integrations/acp/test_client_adapter_behavior.py index 55792c6de..71b063f6d 100644 --- a/tests/integrations/acp/test_client_adapter_behavior.py +++ b/tests/integrations/acp/test_client_adapter_behavior.py @@ -21,11 +21,13 @@ import pytest from acp import RequestError +from acp.helpers import start_tool_call, update_tool_call from pydantic import BaseModel from band.adapters.copilot_acp import CopilotACPAdapter from band.adapters.cursor_acp import CursorACPAdapter from band.adapters.omp_acp import OmpACPAdapter +from band.core.exceptions import InputNotAccepted from band.core.types import Capability from band.integrations.acp.client_adapter import ( HISTORY_REPLAY_HEADER, @@ -39,6 +41,7 @@ from band.runtime.formatters import build_participants_message from band.runtime.prompts import COMMUNICATION_INSTRUCTIONS, TURN_COMPLETION_GUIDANCE from band.runtime.tools import BAND_MCP_SERVER_NAME, BandTool +from band.testing import FakeAgentTools from tests.integrations.acp.acp_toolkit import ( FakeACPAgent, acp_adapter, @@ -46,6 +49,7 @@ live_line, started_acp_adapter, ) +from tests.integrations.acp.conftest import make_platform_message from tests.mcpbackends import backends_created_by from tests.mcpclient import ( STORE_MEMORY_ARGS, @@ -1331,3 +1335,105 @@ async def test_acp_mandatory_transport_contract_refreshes_first_prompt( await session.send("new message", history=history) assert agent.prompt_texts()[0].count(COMMUNICATION_INSTRUCTIONS) == 1 assert agent.prompt_texts()[0].count(TURN_COMPLETION_GUIDANCE) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("restored", [False, True]) +async def test_busy_first_prompt_preserves_session_context(restored: bool) -> None: + agent = FakeACPAgent(supports_session_load=True) + if restored: + agent.knows_session("session-1") + history = rehydration_history( + "[Marco]: My favorite color is blue.", + session="session-1" if restored else None, + ) + + @agent.on_prompt + async def reject_once(peer: FakeACPAgent, sid: str) -> None: + if len(peer.prompts) == 1: + raise RequestError(-32003, "Session is busy", {"reason": "session_busy"}) + await peer.say(sid, "Blue.") + + async with acp_adapter(agent) as session: + with pytest.raises(InputNotAccepted): + await session.send( + "What is my favorite color?", bootstrap=True, history=history + ) + assert session.last_reply.outline == [] + reply = await session.send( + "What is my favorite color?", bootstrap=True, history=history + ) + await session.send("Next question") + + assert [request["session_id"] for request in agent.prompts] == [ + agent.prompts[0]["session_id"] + ] * 3 + rejected, accepted, subsequent = agent.prompt_texts() + assert COMMUNICATION_INSTRUCTIONS in rejected + assert COMMUNICATION_INSTRUCTIONS in accepted + assert COMMUNICATION_INSTRUCTIONS not in subsequent + assert (REPLAY_HEADER_LINE in accepted) is not restored + assert ("[Marco]:" in accepted) is not restored + assert REPLAY_HEADER_LINE not in subsequent + assert reply.thoughts == ["Blue."] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("code", "data"), + [ + (-32003, None), + (-32003, {"reason": "other"}), + (-32603, {"reason": "session_busy"}), + ], +) +async def test_only_exact_typed_busy_response_preserves_session( + code: int, data: dict[str, str] | None +) -> None: + agent = FakeACPAgent() + + @agent.on_prompt + async def fail_once(peer: FakeACPAgent, sid: str) -> None: + if len(peer.prompts) == 1: + raise RequestError(code, "Session is busy", data) + await peer.say(sid, "Recovered.") + + async with acp_adapter(agent) as session: + with pytest.raises(RequestError): + await session.send("first") + assert "error" in session.last_reply.outline + await session.send("second") + + assert len(agent.sessions) == 2 + + +@pytest.mark.asyncio +async def test_rejected_acp_activity_cannot_complete_incoming_turn() -> None: + agent = FakeACPAgent() + + @agent.on_prompt + async def autonomous_activity(peer: FakeACPAgent, sid: str) -> None: + await peer.emit(sid, start_tool_call("other-work", "band_no_reply")) + await peer.emit( + sid, update_tool_call("other-work", raw_output="Done", status="completed") + ) + await peer.say(sid, "Autonomous final text") + raise RequestError(-32003, "Session is busy", {"reason": "session_busy"}) + + tools = FakeAgentTools() + async with acp_adapter(agent) as session: + with pytest.raises(InputNotAccepted): + await session.adapter.on_message( + make_platform_message("Pending work", room_id="room-1"), + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + assert not tools.turn.complete + assert not tools.turn.replied + assert tools.messages_sent == [] + assert all(event["message_type"] != "error" for event in tools.events_sent) diff --git a/tests/integrations/codex/test_adapter_e2e.py b/tests/integrations/codex/test_adapter_e2e.py index cfb3af405..7b9b1d706 100644 --- a/tests/integrations/codex/test_adapter_e2e.py +++ b/tests/integrations/codex/test_adapter_e2e.py @@ -29,6 +29,7 @@ tool_call_request, turn_completed, ) +from tests.adapters.dispatch import deliver_event def _platform_message(content: str, *, room_id: str = "room-1") -> PlatformMessage: @@ -278,13 +279,14 @@ async def test_manual_approval_resolved_by_out_of_band_approve_command() -> None await adapter.on_started("Codex Agent", "Integration test agent") first_turn = asyncio.create_task( - adapter.on_event( + deliver_event( + adapter, _agent_input( "run protected command", tools, room_id="room-1", is_session_bootstrap=True, - ) + ), ) ) @@ -296,13 +298,14 @@ async def test_manual_approval_resolved_by_out_of_band_approve_command() -> None else: pytest.fail("Approval notification not sent within timeout") - await adapter.on_event( + await deliver_event( + adapter, _agent_input( "/approve ap-1", tools, room_id="room-1", is_session_bootstrap=False, - ) + ), ) await asyncio.wait_for(first_turn, timeout=2.0) # on_event returned once the room was asked; the turn itself runs on. diff --git a/tests/preprocessing/test_commitment.py b/tests/preprocessing/test_commitment.py new file mode 100644 index 000000000..f3bfd2f11 --- /dev/null +++ b/tests/preprocessing/test_commitment.py @@ -0,0 +1,118 @@ +"""Context remains owed until an input can have reached the provider.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from band.agent import Agent +from band.core.exceptions import InputNotAccepted +from band.core.protocols import AgentToolsProtocol +from band.core.simple_adapter import SimpleAdapter +from band.core.types import AgentInput, PlatformMessage +from band.preprocessing.default import DefaultPreprocessor +from band.runtime.execution import ExecutionContext +from band.runtime.types import SessionConfig +from tests.preprocessing.test_default import make_message_event + + +class ContextAdapter(SimpleAdapter[Any]): + def __init__(self) -> None: + super().__init__() + self.inputs: list[AgentInput] = [] + self.error: Exception | None = InputNotAccepted("occupied") + self.during_attempt: Callable[[], None] = lambda: None + + async def on_event(self, inp: AgentInput) -> None: + self.inputs.append(inp) + self.during_attempt() + if self.error is not None: + raise self.error + + async def on_message( + self, + msg: PlatformMessage, + tools: AgentToolsProtocol, + history: Any, + participants_msg: str | None, + contacts_msg: str | None, + *, + is_session_bootstrap: bool, + room_id: str, + ) -> None: + raise AssertionError("on_event owns this test boundary") + + async def on_cleanup(self, room_id: str) -> None: + pass + + +@pytest.fixture +def context() -> ExecutionContext: + ctx = ExecutionContext( + "room-1", + MagicMock(), + AsyncMock(), + config=SessionConfig(enable_context_hydration=False), + ) + ctx.set_participants([{"id": "user-1", "name": "Alice", "type": "User"}]) + ctx.inject_system_message("First contact update") + return ctx + + +async def test_rejected_context_is_refreshed_and_concurrent_updates_remain_owed( + context: ExecutionContext, +) -> None: + adapter = ContextAdapter() + runtime = MagicMock(agent_id="agent-1") + agent = Agent(runtime=runtime, adapter=adapter) + event = make_message_event() + with pytest.raises(InputNotAccepted): + await agent._on_execute(context, event) + + assert not context.is_llm_initialized + assert context.participants_changed() + assert context.peek_pending_system_messages() == ["First contact update"] + + context.set_participants([{"id": "user-1", "name": "Alicia", "type": "User"}]) + context.inject_system_message("Second contact update") + + def concurrent_updates() -> None: + context.set_participants([{"id": "user-1", "name": "Ally", "type": "User"}]) + context.inject_system_message("Third contact update") + + adapter.error = None + adapter.during_attempt = concurrent_updates + await agent._on_execute(context, event) + accepted = adapter.inputs[-1] + assert accepted.is_session_bootstrap + assert accepted.participants_msg is not None + assert "Alicia" in accepted.participants_msg + assert accepted.contacts_msg == "First contact update\nSecond contact update" + assert context.is_llm_initialized + + prepared = await DefaultPreprocessor().process(context, event, "agent-1") + assert prepared is not None + assert not prepared.is_session_bootstrap + assert prepared.participants_msg is not None + assert "Ally" in prepared.participants_msg + assert prepared.contacts_msg == "Third contact update" + assert prepared.commit_context is not None + prepared.commit_context() + assert not context.participants_changed() + assert context.peek_pending_system_messages() == [] + + +async def test_real_failure_conservatively_commits_prepared_context( + context: ExecutionContext, +) -> None: + adapter = ContextAdapter() + adapter.error = ValueError("provider acceptance unknown") + agent = Agent(runtime=MagicMock(agent_id="agent-1"), adapter=adapter) + with pytest.raises(ValueError): + await agent._on_execute(context, make_message_event()) + assert context.is_llm_initialized + assert not context.participants_changed() + assert context.peek_pending_system_messages() == [] diff --git a/tests/preprocessing/test_contacts_msg.py b/tests/preprocessing/test_contacts_msg.py index 243768877..07a76c6ea 100644 --- a/tests/preprocessing/test_contacts_msg.py +++ b/tests/preprocessing/test_contacts_msg.py @@ -23,7 +23,7 @@ def mock_execution_context(): ctx.is_llm_initialized = False ctx.participants = [] ctx.current_scope = None - ctx.get_pending_system_messages = MagicMock(return_value=[]) + ctx.peek_pending_system_messages = MagicMock(return_value=[]) ctx.mark_llm_initialized = MagicMock() # AgentTools.from_context needs ctx.link.rest ctx.link = MagicMock() @@ -59,7 +59,7 @@ async def test_preprocessor_includes_contacts_msg( self, mock_execution_context, sample_message_event ): """AgentInput should have contacts_msg field.""" - mock_execution_context.get_pending_system_messages = MagicMock( + mock_execution_context.peek_pending_system_messages = MagicMock( return_value=["[Contacts]: @bob is now a contact"] ) @@ -71,26 +71,30 @@ async def test_preprocessor_includes_contacts_msg( assert result is not None assert result.contacts_msg == "[Contacts]: @bob is now a contact" - async def test_preprocessor_drains_from_context( + async def test_preprocessor_commits_prepared_context( self, mock_execution_context, sample_message_event ): - """Preprocessor should call get_pending_system_messages.""" - mock_execution_context.get_pending_system_messages = MagicMock( + """Preparation leaves context owed until the attempt commits.""" + mock_execution_context.peek_pending_system_messages = MagicMock( return_value=["test message"] ) preprocessor = DefaultPreprocessor() - await preprocessor.process( + result = await preprocessor.process( mock_execution_context, sample_message_event, "agent-123" ) - mock_execution_context.get_pending_system_messages.assert_called_once() + mock_execution_context.commit_system_messages.assert_not_called() + result.commit_context() + mock_execution_context.commit_system_messages.assert_called_once_with( + ["test message"] + ) async def test_preprocessor_contacts_msg_none_when_empty( self, mock_execution_context, sample_message_event ): """contacts_msg should be None if no updates.""" - mock_execution_context.get_pending_system_messages = MagicMock(return_value=[]) + mock_execution_context.peek_pending_system_messages = MagicMock(return_value=[]) preprocessor = DefaultPreprocessor() result = await preprocessor.process( @@ -104,7 +108,7 @@ async def test_preprocessor_contacts_msg_multiple( self, mock_execution_context, sample_message_event ): """Multiple updates should be joined with newline.""" - mock_execution_context.get_pending_system_messages = MagicMock( + mock_execution_context.peek_pending_system_messages = MagicMock( return_value=[ "[Contacts]: @alice is now a contact", "[Contacts]: @bob is now a contact", @@ -123,38 +127,3 @@ async def test_preprocessor_contacts_msg_multiple( assert "@bob" in result.contacts_msg assert "@charlie" in result.contacts_msg assert result.contacts_msg.count("\n") == 2 - - -class TestDrainSystemMessages: - """Tests for _drain_system_messages helper.""" - - def test_drain_returns_none_when_empty(self, mock_execution_context): - """Should return None if no messages.""" - mock_execution_context.get_pending_system_messages = MagicMock(return_value=[]) - - preprocessor = DefaultPreprocessor() - result = preprocessor._drain_system_messages(mock_execution_context) - - assert result is None - - def test_drain_joins_multiple_messages(self, mock_execution_context): - """Should join multiple messages with newlines.""" - mock_execution_context.get_pending_system_messages = MagicMock( - return_value=["Message 1", "Message 2", "Message 3"] - ) - - preprocessor = DefaultPreprocessor() - result = preprocessor._drain_system_messages(mock_execution_context) - - assert result == "Message 1\nMessage 2\nMessage 3" - - def test_drain_single_message(self, mock_execution_context): - """Should return single message without newline.""" - mock_execution_context.get_pending_system_messages = MagicMock( - return_value=["Single message"] - ) - - preprocessor = DefaultPreprocessor() - result = preprocessor._drain_system_messages(mock_execution_context) - - assert result == "Single message" diff --git a/tests/preprocessing/test_default.py b/tests/preprocessing/test_default.py index 1f29cf1c3..063917c39 100644 --- a/tests/preprocessing/test_default.py +++ b/tests/preprocessing/test_default.py @@ -1,5 +1,7 @@ """Tests for DefaultPreprocessor.""" +from __future__ import annotations + from unittest.mock import AsyncMock, MagicMock, patch from band.client.streaming import MessageCreatedPayload, MessageMetadata @@ -77,6 +79,7 @@ def make_mock_ctx( ctx.participants_changed = MagicMock(return_value=participants_changed) ctx.mark_llm_initialized = MagicMock() ctx.mark_participants_sent = MagicMock() + ctx.peek_pending_system_messages = MagicMock(return_value=[]) ctx.get_context = AsyncMock( return_value=MagicMock( messages=history_messages or [], @@ -355,8 +358,10 @@ async def test_marks_llm_initialized_on_bootstrap(self): "band.preprocessing.default.check_and_format_participants" ) as mock_participants: mock_participants.return_value = None - await preprocessor.process(ctx, event, agent_id="agent-1") + result = await preprocessor.process(ctx, event, agent_id="agent-1") + ctx.mark_llm_initialized.assert_not_called() + result.commit_context() ctx.mark_llm_initialized.assert_called_once() async def test_does_not_mark_initialized_when_already_initialized(self): diff --git a/tests/runtime/conftest.py b/tests/runtime/conftest.py index 1ad223c9c..e1df75413 100644 --- a/tests/runtime/conftest.py +++ b/tests/runtime/conftest.py @@ -233,6 +233,7 @@ async def _get_context( async def _get_next(*_args: Any, **_kwargs: Any) -> PlatformMessage | None: return sequence.pop(0) if sequence else None + link.get_actionable_message = AsyncMock(return_value=None) link.get_next_message = AsyncMock(side_effect=_get_next) link.mark_processing = AsyncMock() link.mark_processed = AsyncMock() diff --git a/tests/runtime/helpers.py b/tests/runtime/helpers.py index dec9b8a54..a32b648af 100644 --- a/tests/runtime/helpers.py +++ b/tests/runtime/helpers.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import sys from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager from datetime import UTC, datetime @@ -12,6 +13,7 @@ import httpx from band.client.rest import AsyncRestClient +from band.core.exceptions import InputNotAccepted from band.core.memory_types import ( MemorySegment, MemoryStoreScope, @@ -23,6 +25,7 @@ from band.core.types import PlatformMessage from band.preprocessing.default import DefaultPreprocessor from band.runtime.execution import ExecutionContext +from band.runtime.tools.agent import AgentTools from band.runtime.types import SessionConfig from tests.conftest import make_message_event from tests.identifiers import UUID_ID @@ -79,7 +82,22 @@ def run_through( async def handler(ctx: ExecutionContext, event: Any) -> None: inp = await preprocessor.process(ctx=ctx, event=event, agent_id=AGENT_ID) if inp is not None: - await adapter.on_event(inp) + try: + await adapter.on_event(inp) + finally: + if ( + not isinstance(sys.exception(), InputNotAccepted) + and inp.commit_context is not None + ): + inp.commit_context() + + async def control(ctx: ExecutionContext, event: Any) -> bool: + msg = preprocessor.message(ctx=ctx, event=event, agent_id=AGENT_ID) + return ( + await adapter.try_handle_control(msg, AgentTools.from_context(ctx)) + if msg is not None + else False + ) return ExecutionContext( ROOM_ID, @@ -87,6 +105,7 @@ async def handler(ctx: ExecutionContext, event: Any) -> None: handler, config=config or SessionConfig(enable_context_hydration=False), agent_id=AGENT_ID, + on_message_control=control, ) @@ -140,6 +159,7 @@ def __init__(self, *, stopped: bool = False) -> None: self.posts: list[str] = [] self.processing_list_reads = 0 self.next_reads: list[float] = [] + self.actionable_cursors: list[str | None] = [] def add_message(self, message_id: str) -> None: self.messages.append( @@ -160,10 +180,45 @@ def add_message(self, message_id: str) -> None: def answer(self, request: httpx.Request) -> httpx.Response: path = request.url.path tail = path.rsplit("/", 1)[-1] + if tail == "me": + return httpx.Response( + 200, + json={ + "data": { + "id": AGENT_ID, + "name": "Agent", + "handle": "agent", + "owner_uuid": "user-1", + "feature_flags": {}, + "inserted_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + } + }, + ) if tail == "messages" and request.method == "GET": - self.processing_list_reads += 1 + if request.url.params.get("status") == "processing": + self.processing_list_reads += 1 + return httpx.Response( + 200, json={"data": self.messages, "metadata": {"total_pages": 1}} + ) + cursor = request.url.params.get("cursor") + self.actionable_cursors.append(cursor) + start = next( + (i + 1 for i, m in enumerate(self.messages) if m["id"] == cursor), 0 + ) + limit = int(request.url.params.get("limit", "100")) + page = self.messages[start : start + limit] + has_more = start + limit < len(self.messages) return httpx.Response( - 200, json={"data": self.messages, "metadata": {"total_pages": 1}} + 200, + json={ + "data": page, + "metadata": { + "limit": limit, + "has_more": has_more, + "next_cursor": page[-1]["id"] if has_more else None, + }, + }, ) if tail == "next": self.next_reads.append(asyncio.get_running_loop().time()) diff --git a/tests/runtime/test_deferral.py b/tests/runtime/test_deferral.py new file mode 100644 index 000000000..08efa764d --- /dev/null +++ b/tests/runtime/test_deferral.py @@ -0,0 +1,446 @@ +"""Unaccepted work shares the host scheduler with active-turn controls.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from band.core.exceptions import InputNotAccepted +from band.core.simple_adapter import SimpleAdapter +from band.core.types import ( + SYNTHETIC_CONTACT_EVENTS_SENDER_ID, + SYNTHETIC_SENDER_TYPE, +) +from band.platform.event import ReconnectedEvent +from band.platform.link import BandLink +from band.runtime.oneshot import OneShotInvoker, OneShotStatus +from band.runtime.types import SessionConfig +from tests.conftest import make_message_event +from tests.runtime.conftest import wait_for_condition +from tests.runtime.helpers import ( + AGENT_ID, + ROOM_ID, + LifecyclePlatform, + rest_client_over, + run_through, +) + + +class ApprovalAdapter(SimpleAdapter[Any]): + """A detached turn owns its tools until its approval is consumed.""" + + def __init__(self) -> None: + super().__init__() + self.active_tools: Any = None + self.completed: list[str] = [] + self.attempted: list[str] = [] + self.controls: list[str] = [] + self.reject = False + + async def try_handle_control(self, msg: Any, tools: Any) -> bool: + if msg.content != "approve": + return False + self.controls.append(msg.id) + if self.active_tools is not None: + self.active_tools.turn.settle() + self.completed.append("A") + self.active_tools = None + return True + + async def on_message(self, msg: Any, tools: Any, *args: Any, **kwargs: Any) -> None: + self.attempted.append(msg.id) + if self.active_tools is not None or self.reject: + raise InputNotAccepted() + if msg.id == "A": + tools.turn.detach() + self.active_tools = tools + else: + self.completed.append(msg.id) + tools.turn.settle() + + +def envelope(message_id: str) -> dict[str, Any]: + return { + "event_type": "message_created", + "room_id": ROOM_ID, + "payload": { + "id": message_id, + "content": "approve" if message_id == "C" else "ordinary", + "sender_id": "user-1", + "sender_type": "User", + "message_type": "text", + "inserted_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + }, + } + + +@pytest.mark.parametrize("recovery", ["live", "missed", "reconnect", "startup"]) +async def test_approval_beyond_first_page_releases_ordered_work(recovery: str) -> None: + peer = LifecyclePlatform() + peer.add_message("A") + adapter = ApprovalAdapter() + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + ctx = run_through( + adapter, + link, + config=SessionConfig( + enable_context_hydration=False, + enable_working_state=False, + idle_resync_seconds=0.01, + idle_resync_max_seconds=0.01, + max_message_retries=1, + ), + ) + if recovery == "startup": + peer.add_message("B") + await ctx.start() + traffic = asyncio.Event() + + async def ordinary_traffic() -> None: + while not traffic.is_set(): + await ctx.on_event(make_message_event(room_id=ROOM_ID, msg_id="D0")) + await asyncio.sleep(0.001) + + traffic_task: asyncio.Task[None] | None = None + try: + await wait_for_condition(lambda: peer.marked("processed") == ["A"]) + original_tools = adapter.active_tools + if recovery != "startup": + peer.add_message("B") + await ctx.on_event(make_message_event(room_id=ROOM_ID, msg_id="B")) + await wait_for_condition(lambda: "B" in adapter.attempted) + assert peer.marked("processed") == ["A"] + assert peer.marked("failed") == [] + # More than one chronological page hides a missed approval from /next. + for i in range(100): + peer.add_message(f"D{i}") + peer.add_message("C") + peer.messages[-1]["content"] = "approve" + if recovery == "live": + await ctx.on_event( + make_message_event(room_id=ROOM_ID, msg_id="C", content="approve") + ) + elif recovery == "reconnect": + await ctx.on_event(ReconnectedEvent()) + # Sustained ordinary delivery cannot rewind an in-progress scan. + traffic_task = asyncio.create_task(ordinary_traffic()) + for i in range(5): + await ctx.on_event(make_message_event(room_id=ROOM_ID, msg_id=f"D{i}")) + await wait_for_condition( + lambda: "D99" in peer.marked("processed"), timeout=3 + ) + assert original_tools.turn.complete + assert adapter.completed == ["A", "B", *[f"D{i}" for i in range(100)]] + assert adapter.controls == ["C"] + assert peer.marked("processed") == [ + "A", + "C", + "B", + *[f"D{i}" for i in range(100)], + ] + assert peer.marked("failed") == [] + assert any(cursor is not None for cursor in peer.actionable_cursors) + # A terminal duplicate cannot reopen or reapply the approval. + await ctx.on_event( + make_message_event(room_id=ROOM_ID, msg_id="C", content="approve") + ) + await asyncio.sleep(0) + assert adapter.controls == ["C"] + finally: + traffic.set() + if traffic_task is not None: + await traffic_task + await ctx.stop() + + +async def test_synthetic_work_survives_stop_and_rejection_in_receive_order() -> None: + peer = LifecyclePlatform(stopped=True) + adapter = ApprovalAdapter() + adapter.reject = True + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + ctx = run_through( + adapter, + link, + config=SessionConfig( + enable_context_hydration=False, enable_working_state=False + ), + ) + ctx.stop_room() + for message_id in ["local-B", "local-D"]: + await ctx._process_event( + make_message_event( + room_id=ROOM_ID, + msg_id=message_id, + sender_type=SYNTHETIC_SENDER_TYPE, + sender_id=SYNTHETIC_CONTACT_EVENTS_SENDER_ID, + ) + ) + assert adapter.completed == [] + peer.stopped = False + await ctx.resume_room() + await ctx._reconcile() + assert adapter.completed == [] + adapter.reject = False + await ctx._reconcile() + assert adapter.completed == ["local-B", "local-D"] + assert peer.requested_marks == [] + await ctx.stop() + + +async def test_oneshot_control_only_acknowledges_itself_and_rejection_never_drains() -> ( + None +): + peer = LifecyclePlatform() + peer.add_message("B") + peer.add_message("C") + peer.messages[-1]["content"] = "approve" + adapter = ApprovalAdapter() + adapter.reject = True + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + invoker = OneShotInvoker(link=link, adapter=adapter, agent_id=AGENT_ID) + await invoker.startup() + result = await invoker.handle_event(envelope("B")) + assert result["status"] == OneShotStatus.NOT_ACCEPTED + assert peer.marked("processed") == peer.marked("failed") == [] + result = await invoker.handle_event(envelope("C")) + assert result["status"] == OneShotStatus.DONE + assert peer.marked("processed") == ["C"] + assert "drained" not in result + assert adapter.attempted == ["B"] + # Neither a duplicate control nor the old ordinary snapshot can drain B. + await invoker.handle_event(envelope("C")) + assert adapter.controls == ["C"] + assert [message["id"] for message in peer.messages] == ["B"] + + +async def test_stop_during_synthetic_execution_retains_work_for_play() -> None: + peer = LifecyclePlatform() + entered = asyncio.Event() + blocker = asyncio.Event() + + class InterruptibleAdapter(ApprovalAdapter): + async def on_message( + self, msg: Any, tools: Any, *args: Any, **kwargs: Any + ) -> None: + if not entered.is_set(): + entered.set() + await blocker.wait() + await super().on_message(msg, tools, *args, **kwargs) + + adapter = InterruptibleAdapter() + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + ctx = run_through( + adapter, + link, + config=SessionConfig( + enable_context_hydration=False, + enable_working_state=False, + idle_resync_seconds=0.01, + idle_resync_max_seconds=0.01, + ), + ) + await ctx.start() + try: + await ctx.on_event( + make_message_event( + room_id=ROOM_ID, + msg_id="local-B", + sender_type=SYNTHETIC_SENDER_TYPE, + sender_id=SYNTHETIC_CONTACT_EVENTS_SENDER_ID, + ) + ) + await entered.wait() + peer.stopped = True + ctx.stop_room() + await wait_for_condition(lambda: ctx._active_cycle_task is None) + assert adapter.completed == [] + peer.stopped = False + await ctx.resume_room() + await wait_for_condition(lambda: adapter.completed == ["local-B"]) + assert peer.requested_marks == [] + finally: + await ctx.stop() + + +async def test_busy_attempts_preserve_previous_execution_failure_budget() -> None: + peer = LifecyclePlatform() + peer.add_message("B") + mode = "failure" + invoked: list[str] = [] + + class FailingAdapter(ApprovalAdapter): + async def on_message( + self, msg: Any, tools: Any, *args: Any, **kwargs: Any + ) -> None: + invoked.append(mode) + if mode == "busy": + raise InputNotAccepted() + raise OSError("provider transport uncertainty") + + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + ctx = run_through( + FailingAdapter(), + link, + config=SessionConfig( + enable_context_hydration=False, + enable_working_state=False, + max_message_retries=2, + report_turn_failures_to_room=False, + ), + ) + event = make_message_event(room_id=ROOM_ID, msg_id="B") + await ctx._process_event(event) + mode = "busy" + for _ in range(5): + await ctx._reconcile() + assert peer.marked("failed") == ["B"] + assert peer.marked("processed") == [] + mode = "failure" + await ctx._reconcile() + await ctx._reconcile() + assert peer.marked("failed") == ["B", "B"] + assert invoked == ["failure", *["busy"] * 5, "failure"] + await ctx.stop() + + +async def test_missed_play_releases_busy_work_at_existing_reconciliation_boundary() -> ( + None +): + peer = LifecyclePlatform() + peer.add_message("B") + adapter = ApprovalAdapter() + adapter.reject = True + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + ctx = run_through( + adapter, + link, + config=SessionConfig( + enable_context_hydration=False, enable_working_state=False + ), + ) + await ctx._reconcile() + ctx.stop_room() + peer.stopped = True + await ctx._reconcile() + assert peer.marked("processed") == [] + # PLAY was lost on the socket; /next's accepted gate discovers it. + peer.stopped = False + adapter.reject = False + peer.add_message("D") + await ctx._reconcile() + assert adapter.completed == ["B", "D"] + assert peer.marked("processed") == ["B", "D"] + assert peer.marked("failed") == [] + await ctx.stop() + + +async def test_stale_processed_control_cannot_be_reopened_by_oneshot() -> None: + peer = LifecyclePlatform() + peer.add_message("C") + peer.messages[-1]["metadata"] = { + "delivery_status": {AGENT_ID: {"status": "processed"}} + } + adapter = ApprovalAdapter() + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + invoker = OneShotInvoker(link=link, adapter=adapter, agent_id=AGENT_ID) + await invoker.startup() + result = await invoker.handle_event(envelope("C")) + assert result["status"] == OneShotStatus.ALREADY_PROCESSED + assert adapter.controls == adapter.attempted == [] + assert peer.requested_marks == [] + + +@pytest.mark.parametrize("hosting", ["runtime", "oneshot"]) +async def test_real_control_failure_keeps_existing_failed_delivery_policy( + hosting: str, +) -> None: + peer = LifecyclePlatform() + peer.add_message("B") + peer.add_message("C") + peer.messages[-1]["content"] = "approve" + + class BrokenControlAdapter(ApprovalAdapter): + async def try_handle_control(self, msg: Any, tools: Any) -> bool: + if msg.content == "approve": + raise OSError("control transport failed") + return False + + adapter = BrokenControlAdapter() + adapter.reject = True + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + if hosting == "runtime": + ctx = run_through( + adapter, + link, + config=SessionConfig( + enable_context_hydration=False, + enable_working_state=False, + max_message_retries=1, + report_turn_failures_to_room=False, + ), + ) + await ctx._reconcile() + await ctx._reconcile() + assert peer.marked("failed") == ["C"] + await ctx.stop() + else: + invoker = OneShotInvoker(link=link, adapter=adapter, agent_id=AGENT_ID) + await invoker.startup() + with pytest.raises(OSError, match="control transport failed"): + await invoker.handle_event(envelope("C")) + assert peer.marked("failed") == ["C"] + assert peer.marked("processed") == [] + assert "C" not in adapter.attempted + + +async def test_stop_between_control_discovery_and_claim_prevents_provider_effects() -> ( + None +): + peer = LifecyclePlatform() + peer.add_message("B") + peer.add_message("C") + peer.messages[-1]["content"] = "approve" + adapter = ApprovalAdapter() + adapter.reject = True + + def stopped_control_gate(request: Any) -> Any: + response = peer.answer(request) + if request.url.path.endswith("/messages"): + peer.stopped = True + return response + + async with rest_client_over(stopped_control_gate) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + ctx = run_through( + adapter, + link, + config=SessionConfig( + enable_context_hydration=False, enable_working_state=False + ), + ) + await ctx._reconcile() + assert adapter.controls == [] + assert peer.requested("processing") == ["B", "C"] + assert peer.marked("processing") == ["B"] + assert peer.marked("processed") == peer.marked("failed") == [] + await ctx.stop() diff --git a/tests/runtime/test_oneshot.py b/tests/runtime/test_oneshot.py index 7ddfa9c28..daf3ee85c 100644 --- a/tests/runtime/test_oneshot.py +++ b/tests/runtime/test_oneshot.py @@ -73,6 +73,7 @@ def _make_adapter_mock() -> MagicMock: async def _capture(inp: Any) -> None: adapter.received_input = inp + adapter.try_handle_control = AsyncMock(return_value=False) adapter.on_started = AsyncMock() adapter.on_event = AsyncMock(side_effect=_capture) adapter.on_cleanup = AsyncMock() diff --git a/tests/test_agent.py b/tests/test_agent.py index b3ce0e10b..d53e1eed1 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -763,7 +763,13 @@ def claim_single_instance(self) -> None: def release_single_instance(self) -> None: pass - async def start(self, on_execute, on_cleanup=None, on_control=None): + async def start( + self, + on_execute, + on_cleanup=None, + on_control=None, + on_message_control=None, + ): self._on_execute = on_execute # Create a proper MessageEvent that passes through preprocessor From 3511aac97898a07751e8b32ca0514e6f50774e14 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 11 Oct 2026 10:52:24 +0300 Subject: [PATCH 2/7] fix(runtime): explain unaccepted input recovery --- src/band/runtime/execution.py | 18 ++++++++++++----- tests/runtime/test_deferral.py | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index ec8cf0231..aa5ef0211 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -1193,6 +1193,17 @@ def _set_deferred_head(self, message_id: str | None) -> None: self._deferred_message_id = message_id self._control_cursor = None + def _defer_unaccepted_input(self, message_id: str | None) -> None: + if message_id: + self._retry_tracker.discard_attempt(message_id) + self._set_deferred_head(message_id) + logger.debug( + "ExecutionContext %s: Input %s not accepted; " + "keeping it pending for scheduler recovery", + self.room_id, + message_id or "synthetic", + ) + def _retain_synthetic( self, event: PlatformEvent | None, *, first: bool = False ) -> bool: @@ -1678,8 +1689,7 @@ async def _process_claimed_backlog_message( return BacklogProcessResult.ADVANCED except InputNotAccepted: - self._retry_tracker.discard_attempt(msg_id) - self._set_deferred_head(msg_id) + self._defer_unaccepted_input(msg_id) return BacklogProcessResult.NOT_ACCEPTED except RoomExecutionStoppedError: @@ -2373,9 +2383,7 @@ async def _process_event_body( return True except InputNotAccepted: - if msg_id: - self._retry_tracker.discard_attempt(msg_id) - self._set_deferred_head(msg_id) + self._defer_unaccepted_input(msg_id) return False except RoomExecutionStoppedError: diff --git a/tests/runtime/test_deferral.py b/tests/runtime/test_deferral.py index 08efa764d..f0239c48e 100644 --- a/tests/runtime/test_deferral.py +++ b/tests/runtime/test_deferral.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging from typing import Any import pytest @@ -77,6 +78,42 @@ def envelope(message_id: str) -> dict[str, Any]: } +@pytest.mark.parametrize("hosting", ["live", "recovery"]) +async def test_unaccepted_work_logs_why_it_remains_pending( + hosting: str, caplog: pytest.LogCaptureFixture +) -> None: + peer = LifecyclePlatform() + peer.add_message("B") + adapter = ApprovalAdapter() + adapter.reject = True + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + ctx = run_through( + adapter, + link, + config=SessionConfig( + enable_context_hydration=False, enable_working_state=False + ), + ) + with caplog.at_level(logging.DEBUG, logger="band.runtime.execution"): + if hosting == "live": + await ctx._process_event( + make_message_event(room_id=ROOM_ID, msg_id="B") + ) + else: + await ctx._reconcile() + await ctx.stop() + + assert peer.marked("processed") == peer.marked("failed") == [] + rejection = next( + (record for record in caplog.records if "not accepted" in record.message), + None, + ) + assert rejection is not None, "Outstanding work needs a rejection diagnostic" + assert rejection.levelno == logging.DEBUG + + @pytest.mark.parametrize("recovery", ["live", "missed", "reconnect", "startup"]) async def test_approval_beyond_first_page_releases_ordered_work(recovery: str) -> None: peer = LifecyclePlatform() From 45953d1829dd253134efb1e2ca39cb06fc1caa5d Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 11 Oct 2026 10:52:28 +0300 Subject: [PATCH 3/7] refactor(platform): keep actionable lookups in message lifecycle --- src/band/platform/link.py | 12 +++--------- src/band/platform/message_lifecycle.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/band/platform/link.py b/src/band/platform/link.py index 2167f56f9..67f6100bf 100644 --- a/src/band/platform/link.py +++ b/src/band/platform/link.py @@ -599,15 +599,9 @@ async def get_actionable_page( async def get_actionable_message( self, room_id: str, message_id: str ) -> PlatformMessage | None: - cursor: str | None = None - while True: - page = await self.get_actionable_page(room_id, cursor=cursor) - for message in page.messages: - if message.id == message_id: - return message - if page.next_cursor is None: - return None - cursor = page.next_cursor + return await self._messages.get_actionable_message( + self.rest, room_id, message_id + ) async def get_stale_processing_messages( self, room_id: str diff --git a/src/band/platform/message_lifecycle.py b/src/band/platform/message_lifecycle.py index a5f5c06fa..eace6338a 100644 --- a/src/band/platform/message_lifecycle.py +++ b/src/band/platform/message_lifecycle.py @@ -264,6 +264,19 @@ async def get_actionable_page( [_platform_message(item, room_id) for item in response.data], next_cursor ) + async def get_actionable_message( + self, rest: AsyncRestClient, room_id: str, message_id: str + ) -> PlatformMessage | None: + cursor: str | None = None + while True: + page = await self.get_actionable_page(rest, room_id, cursor=cursor) + for message in page.messages: + if message.id == message_id: + return message + if page.next_cursor is None: + return None + cursor = page.next_cursor + async def get_stale_processing_messages( self, rest: AsyncRestClient, room_id: str ) -> list[PlatformMessage]: From c97b50942c0ee0c93f6a2cac170f62e84a171dbf Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 11 Oct 2026 11:30:07 +0300 Subject: [PATCH 4/7] fix(agent): preserve preprocessing policy for controls --- src/band/agent.py | 17 +++--- src/band/core/types.py | 3 +- src/band/runtime/execution.py | 4 -- tests/preprocessing/test_controls.py | 90 ++++++++++++++++++++++++++++ tests/runtime/helpers.py | 35 ++--------- tests/test_agent.py | 1 + 6 files changed, 108 insertions(+), 42 deletions(-) create mode 100644 tests/preprocessing/test_controls.py diff --git a/src/band/agent.py b/src/band/agent.py index 338b2889e..bc5e2eda2 100644 --- a/src/band/agent.py +++ b/src/band/agent.py @@ -17,7 +17,6 @@ from band.preprocessing.default import DefaultPreprocessor from band.runtime.platform_runtime import PlatformRuntime from band.runtime.startup import release_adapter, start_adapter -from band.runtime.tools import AgentTools from band.runtime.types import ( AgentConfig, ContactEventConfig, @@ -27,6 +26,7 @@ ) if TYPE_CHECKING: + from band.core.types import AgentInput from band.platform.event import PlatformEvent from band.runtime.execution import ExecutionContext @@ -460,7 +460,7 @@ async def _on_execute( agent_id=self._runtime.agent_id, ) - if inp is None: + if inp is None or await self._try_control(inp): return rejected = False @@ -475,10 +475,11 @@ async def _on_execute( async def _on_control(self, ctx: ExecutionContext, event: PlatformEvent) -> bool: """Handle controls with their delivery tools, preserving active ownership.""" + inp = await self._preprocessor.process( + ctx=ctx, event=event, agent_id=self._runtime.agent_id + ) + return await self._try_control(inp) if inp is not None else False + + async def _try_control(self, inp: AgentInput) -> bool: handler = getattr(self._adapter, "try_handle_control", None) - if handler is None: - return False - msg = DefaultPreprocessor.message(ctx, event, self._runtime.agent_id) - if msg is None: - return False - return await handler(msg, AgentTools.from_context(ctx)) + return await handler(inp.msg, inp.tools) if handler is not None else False diff --git a/src/band/core/types.py b/src/band/core/types.py index e02f38013..c11170939 100644 --- a/src/band/core/types.py +++ b/src/band/core/types.py @@ -530,7 +530,8 @@ class AgentInput: Contains everything an adapter needs to process a message. History is provided via HistoryProvider for lazy conversion. Custom preprocessors that consume context can supply ``commit_context``; - the host calls it after every attempt except proven non-acceptance. + the host calls it after model attempts except proven non-acceptance. + Controls leave model context owed. """ msg: PlatformMessage diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index aa5ef0211..91670c16c 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -1752,10 +1752,6 @@ async def _invoke_handler(self, event: PlatformEvent) -> None: housekeeping and skip it. """ if isinstance(event, MessageEvent): - if self._on_message_control is not None and await self._on_message_control( - self, event - ): - return await self._execute_message_cycle(event) else: await self._on_execute(self, event) diff --git a/tests/preprocessing/test_controls.py b/tests/preprocessing/test_controls.py new file mode 100644 index 000000000..602918469 --- /dev/null +++ b/tests/preprocessing/test_controls.py @@ -0,0 +1,90 @@ +"""Configured policy governs controls without consuming pending context.""" + +from __future__ import annotations + +from dataclasses import replace +from unittest.mock import MagicMock + +import pytest + +from band.agent import Agent +from band.core.types import AgentInput +from band.platform.event import PlatformEvent +from band.platform.link import BandLink +from band.preprocessing.default import DefaultPreprocessor +from band.runtime.execution import ExecutionContext +from band.runtime.types import SessionConfig +from tests.conftest import make_message_event +from tests.runtime.helpers import AGENT_ID, ROOM_ID, LifecyclePlatform, rest_client_over +from tests.runtime.test_deferral import ApprovalAdapter + + +class ControlPolicy(DefaultPreprocessor): + def __init__(self, rewritten: str | None) -> None: + self.rewritten = rewritten + self.seen: list[str] = [] + + async def process( + self, ctx: ExecutionContext, event: PlatformEvent, agent_id: str + ) -> AgentInput | None: + inp = await super().process(ctx, event, agent_id) + if inp is None: + return None + self.seen.append(inp.msg.id) + if self.rewritten is None: + return None + return replace(inp, msg=replace(inp.msg, content=self.rewritten)) + + +@pytest.mark.parametrize("hosting", ["normal", "pending"]) +@pytest.mark.parametrize( + ("content", "rewritten", "consumed"), + [ + ("approve", None, False), + ("approve", "ordinary", False), + ("permit", "approve", True), + ], + ids=["filtered", "transformed-away", "transformed-control"], +) +async def test_configured_policy_governs_control_delivery( + hosting: str, content: str, rewritten: str | None, consumed: bool +) -> None: + peer = LifecyclePlatform() + peer.add_message("C", content=content) + adapter = ApprovalAdapter() + adapter.reject = True + policy = ControlPolicy(rewritten) + agent = Agent( + runtime=MagicMock(agent_id=AGENT_ID), adapter=adapter, preprocessor=policy + ) + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + ctx = ExecutionContext( + ROOM_ID, + link, + agent._on_execute, + agent_id=AGENT_ID, + on_message_control=agent._on_control, + config=SessionConfig( + enable_context_hydration=False, enable_working_state=False + ), + ) + ctx.inject_system_message("Contact update still owed") + event = make_message_event(room_id=ROOM_ID, msg_id="C", content=content) + if hosting == "normal": + await ctx._process_event(event) + else: + await ctx._try_control(event) + await ctx.stop() + + assert policy.seen == ["C"] + assert adapter.controls == (["C"] if consumed else []) + assert not ctx.is_llm_initialized + assert ctx.participants_changed() + assert ctx.peek_pending_system_messages() == ["Contact update still owed"] + expected_processed = ( + ["C"] if consumed or (hosting == "normal" and rewritten is None) else [] + ) + assert peer.marked("processed") == expected_processed + assert peer.marked("failed") == [] diff --git a/tests/runtime/helpers.py b/tests/runtime/helpers.py index a32b648af..5f4e33591 100644 --- a/tests/runtime/helpers.py +++ b/tests/runtime/helpers.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import sys from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager from datetime import UTC, datetime @@ -12,8 +11,8 @@ import httpx +from band.agent import Agent from band.client.rest import AsyncRestClient -from band.core.exceptions import InputNotAccepted from band.core.memory_types import ( MemorySegment, MemoryStoreScope, @@ -23,9 +22,7 @@ from band.core.protocols import TURN_FAILURE_PROVIDER from band.core.simple_adapter import SimpleAdapter from band.core.types import PlatformMessage -from band.preprocessing.default import DefaultPreprocessor from band.runtime.execution import ExecutionContext -from band.runtime.tools.agent import AgentTools from band.runtime.types import SessionConfig from tests.conftest import make_message_event from tests.identifiers import UUID_ID @@ -77,35 +74,15 @@ def run_through( *, config: SessionConfig | None = None, ) -> ExecutionContext: - preprocessor = DefaultPreprocessor() - - async def handler(ctx: ExecutionContext, event: Any) -> None: - inp = await preprocessor.process(ctx=ctx, event=event, agent_id=AGENT_ID) - if inp is not None: - try: - await adapter.on_event(inp) - finally: - if ( - not isinstance(sys.exception(), InputNotAccepted) - and inp.commit_context is not None - ): - inp.commit_context() - - async def control(ctx: ExecutionContext, event: Any) -> bool: - msg = preprocessor.message(ctx=ctx, event=event, agent_id=AGENT_ID) - return ( - await adapter.try_handle_control(msg, AgentTools.from_context(ctx)) - if msg is not None - else False - ) + agent = Agent(runtime=MagicMock(agent_id=AGENT_ID), adapter=adapter) return ExecutionContext( ROOM_ID, link, - handler, + agent._on_execute, config=config or SessionConfig(enable_context_hydration=False), agent_id=AGENT_ID, - on_message_control=control, + on_message_control=agent._on_control, ) @@ -161,12 +138,12 @@ def __init__(self, *, stopped: bool = False) -> None: self.next_reads: list[float] = [] self.actionable_cursors: list[str | None] = [] - def add_message(self, message_id: str) -> None: + def add_message(self, message_id: str, *, content: str = "hello") -> None: self.messages.append( { "id": message_id, "chat_room_id": ROOM_ID, - "content": "hello", + "content": content, "sender_id": "user-1", "sender_type": "User", "sender_name": "User", diff --git a/tests/test_agent.py b/tests/test_agent.py index d53e1eed1..e54d1085d 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -32,6 +32,7 @@ def mock_adapter(): adapter.on_started = AsyncMock() adapter.on_cleanup = AsyncMock() adapter.on_event = AsyncMock() + adapter.try_handle_control = None return adapter From a86ede24fd6c867f36117b6bdfe964ac69e4d1df Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 11 Oct 2026 11:30:07 +0300 Subject: [PATCH 5/7] fix(slack): forward active-turn controls through wrapper ingress --- src/band/integrations/slack/adapter.py | 11 ++++ tests/integrations/slack/test_controls.py | 69 +++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 tests/integrations/slack/test_controls.py diff --git a/src/band/integrations/slack/adapter.py b/src/band/integrations/slack/adapter.py index 437f64967..72031bba3 100644 --- a/src/band/integrations/slack/adapter.py +++ b/src/band/integrations/slack/adapter.py @@ -701,6 +701,11 @@ def _rehydrate_room(self, room_id: str, raw_history: list[dict[str, Any]]) -> No thread_key, ) + async def try_handle_control( + self, msg: PlatformMessage, tools: AgentToolsProtocol + ) -> bool: + return await self._inner.try_handle_control(msg, tools) + async def on_message( self, msg: PlatformMessage, @@ -733,6 +738,9 @@ async def on_message( binding.app_slug, ) + if await self.try_handle_control(msg, tools): + return + # Status indicator only meaningful in Slack-bound rooms. # Prepend the Slack-context note to participants_msg so the brain # knows which outbound tool to use for the user-facing reply. @@ -868,6 +876,9 @@ async def _invoke_brain_for_slack_event( show_tool_progress=self.config.show_tool_progress, ) + if await self.try_handle_control(synthesized, tools): + return + # Pull thread context from Slack on every event that lands inside # an existing thread (``thread_ts != ts``). Slack does NOT deliver # prior turns with the event payload, and its own Bolt JS diff --git a/tests/integrations/slack/test_controls.py b/tests/integrations/slack/test_controls.py new file mode 100644 index 000000000..651a8ef71 --- /dev/null +++ b/tests/integrations/slack/test_controls.py @@ -0,0 +1,69 @@ +"""Slack ingress preserves active-turn controls and ownership.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from band.core.exceptions import InputNotAccepted +from band.integrations.codex.types import CodexSessionState +from band.testing import events_of_type +from tests.adapters import test_codex_adapter +from tests.adapters.codexturns import final_text, turn_completed +from tests.adapters.test_codex_adapter import ( + ToolSchemaFakeTools, + make_platform_message, + parked_on_approval, +) +from tests.integrations.slack.test_wrapping import _make_adapter, _mention_event + +codex_room = test_codex_adapter.codex_room + + +@pytest.mark.parametrize("entry", ["band", "slack", "recovery"]) +async def test_wrapped_controls_release_original_turn_without_accepting_new_work( + entry: str, codex_room: Any +) -> None: + room = await codex_room( + *parked_on_approval(final_text("Tests pass."), turn_completed()) + ) + wrapper, _, _, _ = _make_adapter(inner=room.adapter) + await room.send("run tests") + original_tools = room.deliveries[0] + + async def deliver(content: str) -> bool | None: + msg = make_platform_message(content=content) + tools = ToolSchemaFakeTools() + match entry: + case "band": + await wrapper.on_message( + msg, + tools, + CodexSessionState(), + None, + None, + is_session_bootstrap=False, + room_id=msg.room_id, + ) + case "slack": + await wrapper._invoke_brain_for_slack_event( + wrapper.config.apps[0], _mention_event(text=content)["event"] + ) + case "recovery": + return await wrapper.try_handle_control(msg, tools) + return None + + if entry == "recovery": + assert await deliver("another ordinary request") is False + else: + with pytest.raises(InputNotAccepted): + await deliver("another ordinary request") + assert room.client.responses == [] + + handled = await deliver("/approve req-10") + if entry == "recovery": + assert handled is True + await room.settled() + assert room.client.responses == [(10, {"decision": "accept"})] + assert events_of_type(original_tools, "thought")[-1]["content"] == "Tests pass." From 093161cdf8b5e22065c35faad18460f2c9d0fdd8 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 11 Oct 2026 11:58:46 +0300 Subject: [PATCH 6/7] refactor(runtime): centralize one-shot failure details --- src/band/runtime/oneshot.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/band/runtime/oneshot.py b/src/band/runtime/oneshot.py index 17166b9f2..98c9cf439 100644 --- a/src/band/runtime/oneshot.py +++ b/src/band/runtime/oneshot.py @@ -86,6 +86,8 @@ logger = logging.getLogger(__name__) +FAILURE_DETAIL_LIMIT = 500 + class OneShotStatus(StrEnum): """``handle_event``/``_process_message_event``'s ``status`` vocabulary. @@ -319,7 +321,8 @@ async def _acknowledge( raise BandConnectionError("Platform refused message completion") case {"decision": "failed"}: try: - await self._link.mark_failed(room_id, message_id, error) + detail = error[:FAILURE_DETAIL_LIMIT] or "error" + await self._link.mark_failed(room_id, message_id, detail) except RoomExecutionStoppedError: raise except Exception: @@ -424,7 +427,7 @@ async def _process_claimed_message_event( room_id=room_id, message_id=msg_id, succeeded=False, - error=str(exc)[:500] or "error", + error=str(exc), ) raise if consumed: @@ -484,7 +487,7 @@ async def _process_claimed_message_event( room_id=room_id, message_id=msg_id, succeeded=False, - error=str(exc)[:500] or "error", + error=str(exc), ) raise From e614a405ac54af4d6f86774533bd20da54e0bb82 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 11 Oct 2026 12:09:03 +0300 Subject: [PATCH 7/7] refactor(preprocessing): remove redundant flow comments --- src/band/preprocessing/default.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/band/preprocessing/default.py b/src/band/preprocessing/default.py index 3b3dc6483..9c5c4506b 100644 --- a/src/band/preprocessing/default.py +++ b/src/band/preprocessing/default.py @@ -51,16 +51,13 @@ async def process( is_bootstrap = not ctx.is_llm_initialized - # Load history on session bootstrap (if hydration enabled) raw_history: list[dict[str, Any]] = [] if is_bootstrap and ctx.config.enable_context_hydration: raw_history = await self._load_history(ctx, msg) - # Check participants participants = ctx.participants participants_msg = check_and_format_participants(ctx, mark_sent=False) - # Get pending system messages (contact broadcasts) system_messages = ctx.peek_pending_system_messages() contacts_msg = "\n".join(system_messages) if system_messages else None @@ -71,7 +68,6 @@ def commit_context() -> None: ctx.mark_participants_sent(participants) ctx.commit_system_messages(system_messages) - # Create tools tools = AgentTools.from_context(ctx) return AgentInput( @@ -90,31 +86,25 @@ def message( ctx: ExecutionContext, event: PlatformEvent, agent_id: str ) -> PlatformMessage | None: """Project a delivery without consuming model context.""" - # Pattern match on tagged union - only handle MessageEvent match event: case MessageEvent(room_id=room_id, payload=msg_data): - pass # Continue processing + pass case _: - return None # Skip non-message events + return None - # msg_data is now MessageCreatedPayload (fully typed) if msg_data is None: return None - # Validate room_id is present (narrows type from str | None to str) if not room_id: logger.error("MessageEvent has no room_id - cannot process") return None - # Skip messages from self if msg_data.sender_type == "Agent" and msg_data.sender_id == agent_id: logger.debug("Room %s: Skipping own message %s", room_id, msg_data.id) return None - # Look up sender name from participants list sender_name = DefaultPreprocessor._lookup_sender_name(ctx, msg_data.sender_id) - # Convert to PlatformMessage (typed attribute access, no dict lookups). # Translate @[[uuid]] mention tokens to @handle like history formatting # does: the platform normalizes mentions to uuid form, and a raw uuid in # the turn misleads the LLM (e.g. into using it as a memory subject_id).