diff --git a/CHANGELOG.md b/CHANGELOG.md index 05a3976..fe42fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,46 @@ ## Unreleased +### Breaking + +- **Collaboration layer renamed `amux` → `rooms`.** The crate, library, and + binary are now `rooms`, and the wire namespace changed accordingly: + - `amux/*` notification/control methods → `rooms/*` (e.g. `amux/turn_started` + → `rooms/turn_started`, `amux/queue_prompt` → `rooms/queue_prompt`). + - `_meta.amux` → `_meta.rooms` (on `session/attach` results and propagated + request metadata); the `session/list` decoration key moved likewise. + - The `amuxTurnId` field is now `roomsTurnId`. + Clients written against the previous `amux/*` releases must update method + names and `_meta` keys. The lower-level core crate/binary stays `acp-mux`. + ### Added +- **Two-crate workspace: core mux vs Rooms layer.** The repo is now a Cargo + workspace with a hard, compiler-enforced boundary: + - `acp-mux` (lib `acp_mux`, binary `acp-mux`) — the standalone generic 1→N + ACP multiplexer. Id translation, response routing, first-writer-wins + agent-request fan-in, `initialize`/`session/new` caching, `fs/*`/`terminal/*` + safety, plain replay/late-join, and an RFD-#533-baseline + `session/attach`/`session/detach`. It contains zero `rooms/*` knowledge and + does not depend on the `rooms` crate. The standalone binary attaches on + `?mux=`. + - `rooms` (lib `rooms`, binary `rooms`) — the Rooms collaboration protocol, + implemented as a `MuxExtension` plugged into the core mux actor. Owns turns, + queue/steer/cancel, presence, segments, `_meta.rooms` attach + enrichment, and all `rooms/*` frames. Depends on `acp-mux`. The `rooms` + binary attaches on `?room=`. + - The boundary is realized through a `MuxExtension` trait + `MuxCtx` + capability surface in core (core ships a no-op extension; `rooms` provides + the real one). There is now a single multiplexer implementation. +- **Standalone `acp-mux` binary.** A pure one-agent-to-many-clients mux with no + collaboration layer, for clients that only need raw ACP mirroring. - **Optional persistent replay store.** `--replay-store ` persists - broadcast-tier room history as append-only JSONL and rehydrates replay - frames/segment bookends on restart. The upstream agent still owns actual - conversation state. + broadcast-tier room history as append-only JSONL and rehydrates the broadcast + replay log on restart so late joiners can recover the transcript via + `historyPolicy: full_lineage`. Segment lineage and current-segment (`full`) + scoping are not reconstructed across restart yet — see the "cross-restart + segment fidelity" limitation in `docs/design/rooms.md`. The upstream agent + still owns actual conversation state. - **Client contract fixtures.** `docs/examples/client-contract/` contains copyable request/response/notification JSON fixtures for `session/attach`, turn lifecycle, queue lifecycle, agent-request lifecycle, replay markers, @@ -15,6 +49,15 @@ ### Changed +- **Library reorganized into two crates.** Core multiplexing moved from + `src/room/state.rs` (`RoomInner`) into `crates/acp-mux/src/mux/` (`MuxCore` + + actor) with no `rooms/*` concerns; the collaboration behavior moved into + `crates/rooms/src/extension/` (`RoomsExtension: MuxExtension`). The `rooms` + crate's `RoomRegistry`/`server` are now thin wrappers over the core + `MuxRegistry::with_extension(...)`. Aside from the `amux/*` → `rooms/*` + namespace rename above, the `rooms` binary's behavior is otherwise unchanged; + the integration suite and `docs/examples/client-contract/` fixtures were + updated to the new namespace and still pass. - **Provider-neutral core contract.** The mainline mux is now documented and implemented as a generic ACP multiplexer / agent mirror rather than a provider-specific adapter. Provider metadata is passed through opaquely; @@ -22,7 +65,7 @@ names, `session/load`, and observable ACP `params.sessionId` changes. - **Docs reframed around rooms, mirrors, and generic ACP clients.** README, roadmap, and design docs now describe `acp-mux` as a reusable ACP room - server with provider-neutral safety defaults and an explicit `amux/*` + server with provider-neutral safety defaults and an explicit `rooms/*` side channel. ### Removed diff --git a/Cargo.lock b/Cargo.lock index 39d9e4c..b75fb40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,7 +18,6 @@ dependencies = [ "tokio-tungstenite", "tracing", "tracing-subscriber", - "url", ] [[package]] @@ -838,6 +837,26 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rooms" +version = "0.1.3" +dependencies = [ + "acp-mux", + "anyhow", + "axum", + "bytes", + "clap", + "futures", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "ryu" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 87b7179..dfaa653 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,31 +1,3 @@ -[package] -name = "acp-mux" -version = "0.1.3" -edition = "2024" - -# The crate (and GitHub repo) is "acp-mux" but the installed binary is -# the shorter "amux". `src/bin/mock_acp.rs` is still auto-detected. -# The library exists so integration tests under `tests/` can use the -# server/registry/protocol modules without spawning the binary. -[lib] -name = "amux" -path = "src/lib.rs" - -[[bin]] -name = "amux" -path = "src/main.rs" - -[dependencies] -anyhow = "1.0.102" -axum = { version = "0.8.9", features = ["ws", "macros"] } -bytes = "1.11.1" -clap = { version = "4.6.1", features = ["derive"] } -futures = "0.3.32" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -thiserror = "2.0.18" -tokio = { version = "1.52.3", features = ["full"] } -tokio-tungstenite = "0.29.0" -tracing = "0.1.44" -tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] } -url = "2.5.8" +[workspace] +members = ["crates/acp-mux", "crates/rooms"] +resolver = "3" diff --git a/README.md b/README.md index d312208..5c02443 100644 --- a/README.md +++ b/README.md @@ -1,209 +1,154 @@ # acp-mux -**Generic ACP multiplexer / agent mirror.** `acp-mux` runs one stdio ACP agent process behind a WebSocket room and mirrors that live agent session to many clients — desktop, phone, web, TUI, or anything else that can speak JSON-RPC over WebSocket. +`acp-mux` is a Rust workspace for running one stdio ACP agent behind a +WebSocket multiplexer. -The mux is provider-neutral. It does not parse provider logs, interpret provider-private metadata, or make one ACP implementation canonical. Provider `_meta` passes through as payload data; mux state is driven by JSON-RPC envelopes, ACP method names, and ACP-visible `sessionId` changes. +It contains two binaries: + +- `acp-mux`: the provider-neutral core mux. +- `rooms`: the Rooms collaboration layer built on top of the core mux. + +The split is intentional. The core knows how to multiplex JSON-RPC ACP traffic. +Rooms adds room UX such as peers, turn lifecycle, queueing, segment lineage, and +streamed replay markers. + +## Workspace Layout ```text -ACP client(s) ── WebSocket JSON-RPC ──► amux ── stdio ACP JSON-RPC ──► ACP agent - phone same room │ any stdio ACP agent - desktop same transcript └─ replay, turn control, presence - web UI same permissions +crates/acp-mux/ core ACP mux library and `acp-mux` binary +crates/rooms/ Rooms collaboration extension and `rooms` binary +docs/ protocol and design notes ``` -## What it does +Use the crate READMEs for exact behavior: -`acp-mux` has one job: **mirror one upstream ACP agent session into a collaborative, reconnectable room.** +- [crates/acp-mux/README.md](crates/acp-mux/README.md) describes the core mux. +- [crates/rooms/README.md](crates/rooms/README.md) describes the Rooms layer. -The project keeps a hard boundary between the generic ACP mux core and the optional AMUX collaboration layer. +## What The Core Does -The **core mux** owns: +The core `acp-mux` binary exposes: -- one agent subprocess per room; -- WebSocket attach/detach for multiple subscribers; -- JSON-RPC request-id translation and response routing; -- broadcast fanout for agent notifications; -- initialize / `session/new` response caching for late joiners; -- replay history and optional persistent replay storage; -- provider-neutral room/session-id tracking needed for reconnect and replay; -- safe defaults for delegated client tools such as `fs/*` and `terminal/*`. +- `GET /healthz` +- `GET /acp` WebSocket attach using `?mux=&peer_id=` +- `GET /acp/sessions?cwd=` transient control-plane `session/list` +- `GET /debug/sessions` core mux snapshots -The **AMUX layer** owns multiplayer conveniences layered on top of the mux: +For each mux id, it starts one ACP agent subprocess and lets multiple WebSocket +subscribers share that subprocess. -- turn bookends and busy-state visibility; -- queue, steer, and active-turn cancellation controls; -- first-writer-wins coordination for agent-initiated permission requests; -- replay-safe room, queue, request, and segment projection frames under `amux/*`. +The core owns: -It does **not** own: +- stdio ACP subprocess management; +- subscriber attach/detach and peer-id collision handling; +- JSON-RPC request-id translation; +- response routing back to the originating subscriber; +- notification broadcast fanout; +- first `initialize` and `session/new` response caching; +- `session/load` canonical session-id rebinding; +- in-memory replay of broadcast frames; +- optional replay persistence for library users; +- first-writer-wins handling for agent-initiated requests; +- pending permission tracking for `session/attach` `pending_only`; +- safe blocking of delegated `fs/*` and `terminal/*` client-tool requests; +- baseline proxy-local `session/attach` and `session/detach`. -- the agent's model, tools, memory, auth, or persisted conversation store; -- provider-specific lifecycle semantics; -- provider-specific stderr/log parsing; -- terminal or filesystem client-tool execution by default; -- changes to upstream ACP agents or the ACP protocol. +The core does not emit `rooms/*` frames and does not know about turns, queues, +rooms, segments, or Rooms metadata. -## Install +## What Rooms Adds -```sh -git clone https://github.com/lsaether/acp-mux -cd acp-mux -cargo build --release -# binary: ./target/release/amux -``` +The `rooms` binary wraps the same core mux with `RoomsExtension`. -## Run with Claude Agent ACP +Rooms adds: -The most useful smoke path is a real ACP coding agent. Zed's Claude Agent adapter is published as `@agentclientprotocol/claude-agent-acp` (`@zed-industries/claude-agent-acp` was the earlier package name and is still what older Zed docs mention). `acp-mux` can run it like any other stdio ACP agent. +- `?room=` naming, with deprecated `?session=` alias; +- `rooms/session_context`, `rooms/peer_joined`, and `rooms/peer_left`; +- turn lifecycle notifications; +- active-turn busy UX; +- queue, steer, unqueue, and active-turn cancel controls; +- agent-request opened/resolved projection frames; +- pending permission reissue after `session/attach`; +- segment tracking across `session/load` and observed ACP `sessionId` changes; +- Rooms-enriched `session/attach` metadata; +- current-segment vs full-lineage history shaping; +- streamed replay markers; +- optional JSONL replay persistence exposed by `--replay-store`. -Use `npx` directly: +## Build ```sh -# Provide auth however the adapter expects it; this is just one common path. -export ANTHROPIC_API_KEY='' +cargo build --workspace +``` -target/release/amux \ - --agent-cmd 'npx -y @agentclientprotocol/claude-agent-acp' \ - --port 8765 +Binaries: + +```text +target/debug/acp-mux +target/debug/rooms ``` -Or install the adapter globally and use its binary: +Release build: ```sh -npm install -g @agentclientprotocol/claude-agent-acp +cargo build --workspace --release +``` + +## Run The Core Mux -target/release/amux \ - --agent-cmd 'claude-agent-acp' \ +```sh +cargo run -p acp-mux -- \ + --agent-cmd 'cat' \ --host 127.0.0.1 \ --port 8765 ``` -Do **not** put shell-only syntax such as `ANTHROPIC_API_KEY=... claude-agent-acp` inside `--agent-cmd`; `amux` splits the command into argv and does not run it through a shell. Put environment variables on the `amux` process itself. - -Then connect clients to: +Connect a client: ```text -ws://127.0.0.1:8765/acp?room=&peer_id=&peer_name=&role= +ws://127.0.0.1:8765/acp?mux=demo&peer_id=desktop ``` -`?room=` is the mux-level collaboration id. Multiple clients using the same `room` share the same upstream Claude Agent subprocess and transcript. `?session=` is accepted as a deprecated alias during the v0.2 transition. - -Attach-aware clients can add `&replay=skip` and then call proxy-local `session/attach` so attach history becomes their single bootstrap source. See [`docs/examples/client-contract`](docs/examples/client-contract) for copyable client frames and expected `amux/*` shapes. - -## HTTP endpoints - -- `GET /healthz` — returns `200 ok`. -- `GET /acp/sessions?cwd=` — cold-start session discovery. Spawns a transient `--agent-cmd`, initializes it, sends `session/list`, returns the agent's `result` JSON, then tears the subprocess down without creating a live room. -- `GET /debug/sessions` — JSON snapshot of live rooms: subscribers, cache state, active turn, queue state, replay length, and segment lineage. - -## CLI flags - -| Flag | Default | Notes | -|---|---:|---| -| `--host` | `127.0.0.1` | Bind address. | -| `--port` | `8765` | TCP port. | -| `--agent-cmd` | _(none)_ | Command + args used to spawn a stdio ACP agent for each new room. Without this, attaches close with WS code `1011`. | -| `--session-ttl-seconds` | `60` | Grace window after the last subscriber leaves. A reconnect within the window keeps the same subprocess alive. | -| `--replay-turns` | `unbounded` | `unbounded` keeps the broadcast log; `0` disables it; `N > 0` is accepted and currently behaves as unbounded with a warning. | -| `--replay-store` | _(none)_ | Optional directory for append-only JSONL replay persistence, one file per room. | -| `--meta-propagate` | `false` | Opt into adding mux trace fields under `params._meta.amux` on subscriber → agent requests. | -| `--unsafe-debug-client-tool-broadcast` | `false` | **Unsafe/debug only.** Raw-broadcasts agent-initiated `fs/*` and `terminal/*` requests; may duplicate side effects. | -| `--emit-segment-frames` | `true` | Emit `amux/segment_started` and `amux/segment_ended` when `session/load` or observed ACP `sessionId` changes rotate the room segment. | -| `--log-level` | `info` | `trace`, `debug`, `info`, `warn`, or `error`. `RUST_LOG` wins when set. | - -## Agent compatibility - -`acp-mux` expects a child process that speaks ACP-style newline-delimited JSON-RPC over stdio. - -| Agent | Status | Notes | -|---|---|---| -| `@agentclientprotocol/claude-agent-acp` / `claude-agent-acp` | ✅ Preferred real-agent example | Zed's Claude Agent adapter, runnable through `npx -y @agentclientprotocol/claude-agent-acp` or a global `claude-agent-acp` install. | -| ACP agents that execute tools inside their own process | ✅ Generic path | Conversation, permission, cancellation, replay, attach/detach, and segment lineage are mux-owned and provider-neutral. | -| ACP agents that delegate `fs/*` or `terminal/*` to the client | ⚠️ Blocked by default | `acp-mux` strips advertised filesystem/terminal client capabilities and returns a structured blocked error if the agent sends these requests anyway. Use `--unsafe-debug-client-tool-broadcast` only for diagnostics. | -| Agents with provider-specific `_meta` | ✅ Opaque passthrough | Metadata remains in payloads for clients that understand it; the mux does not use it to drive lifecycle state. | - -## Room model - -A **room** is the stable mux container named by `?room=`. It owns one upstream subprocess, one subscriber set, one replay log, and one continuous transcript. +Every subscriber with the same `mux=demo` shares the same upstream agent +subprocess until the last subscriber leaves and the TTL expires. -A room can contain multiple **segments**. A segment is the interval where one canonical ACP `sessionId` is active. Segments rotate on provider-neutral signals only: +## Run Rooms -- a successful `session/load`; or -- an agent notification whose `params.sessionId` differs from the active segment's ACP session id. - -The transcript continues across segments. Clients that want only the current head use `historyPolicy: "full"`; clients that want the whole mirrored room history use `historyPolicy: "full_lineage"` on `session/attach`. - -## Routing and replay - -- Subscriber request IDs are rewritten to mux-local IDs before forwarding to the agent. -- Agent responses are rewritten back and sent only to the originating subscriber. -- Agent notifications are broadcast to every subscriber and appended to replay. -- First `initialize` and `session/new` responses are cached so late joiners do not accidentally create a second upstream session. -- Unresolved `session/request_permission` requests are re-issued to attaching clients after `session/attach`; resolved permission history replays as inert `amux/*` lifecycle context, not stale actionable requests. - -## `amux/*` extension namespace - -`amux/*` is the optional AMUX collaboration layer, not the generic ACP mux contract. `acp-mux` keeps ACP frames and mux facts separate: agent-owned ACP frames stay in the ACP namespace; mux-owned collaboration/control events use `amux/*`. - -Common notifications: - -- `amux/session_context` -- `amux/peer_joined`, `amux/peer_left` -- `amux/turn_started`, `amux/turn_complete`, `amux/turn_cancelled` -- `amux/session_busy` -- `amux/control_submitted` -- `amux/queue_item_added`, `amux/queue_item_submitted`, `amux/queue_item_completed`, `amux/queue_item_removed`, `amux/queue_item_orphaned` -- `amux/agent_request_opened`, `amux/agent_request_resolved` -- `amux/replay_started`, `amux/replay_complete` -- `amux/segment_started`, `amux/segment_ended` - -Subscriber control requests: - -- `amux/steer_active_turn` -- `amux/queue_prompt` -- `amux/unqueue_prompt` -- `amux/cancel_active_turn` - -See [`docs/design/amux-namespace.md`](docs/design/amux-namespace.md) for wire shapes. - -## Safety defaults - -ACP includes client-tool methods where an agent can ask the client to read/write files or run terminal commands. In a multi-subscriber room, naïve fanout can duplicate side effects or send a local action to the wrong machine. - -So `acp-mux` is fail-closed by default: - -- strips `initialize.params.clientCapabilities.fs` and `.terminal` before forwarding initialize to the agent; -- blocks runtime `fs/*` and `terminal/*` requests with JSON-RPC `-32000`; -- does not broadcast or replay blocked client-tool requests; -- preserves collaborative `session/request_permission` fanout. +```sh +cargo run -p rooms -- \ + --agent-cmd 'cat' \ + --host 127.0.0.1 \ + --port 8765 +``` -Use `--unsafe-debug-client-tool-broadcast` only when deliberately debugging delegated-client behavior. +Connect a client: -## Persistent replay store +```text +ws://127.0.0.1:8765/acp?room=demo&peer_id=desktop +``` -Pass `--replay-store ` to persist broadcast-tier replay frames to disk. The store is append-only JSONL, one file per room: +Attach-aware Rooms clients usually connect with `replay=skip` and then send +proxy-local `session/attach` to receive a shaped snapshot/history: ```text -/.jsonl +ws://127.0.0.1:8765/acp?room=demo&peer_id=desktop&replay=skip ``` -Persisted frames include mux replay metadata (`replaySeq`, `segmentId`, `recordedAt`) and are rehydrated on restart so late joiners can recover history. The upstream agent's actual conversation state remains the agent's responsibility; use ACP `session/load` or the agent's own persistence for that. - -Operational notes: +## Tests -- `--replay-turns 0` disables both in-memory replay and replay persistence. -- The store is unbounded in the current release. -- Delete the room JSONL file to clear persisted history for that room. -- Do not run multiple `amux` processes writing to the same replay-store directory. +```sh +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +``` -## Docs +## Documentation -- Protocol extension spec: [`docs/design/amux-namespace.md`](docs/design/amux-namespace.md) -- Rooms, segments, and transcript lineage: [`docs/design/rooms.md`](docs/design/rooms.md) -- Client contract fixtures: [`docs/examples/client-contract`](docs/examples/client-contract) -- Roadmap: [`ROADMAP.md`](ROADMAP.md) -- Release notes: [`CHANGELOG.md`](CHANGELOG.md) +- [Core mux README](crates/acp-mux/README.md) +- [Rooms README](crates/rooms/README.md) +- [`rooms/*` namespace](docs/design/rooms-namespace.md) +- [Rooms and segments](docs/design/rooms.md) +- [Client contract examples](docs/examples/client-contract) ## License -MIT — see [LICENSE](LICENSE). +MIT. See [LICENSE](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md index 088b3dd..bc5458c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,13 @@ # acp-mux roadmap -`acp-mux` is a generic ACP multiplexer / agent mirror: one upstream stdio ACP agent process, many WebSocket clients, one shared room transcript. The generic mux core stays small and provider-neutral; the AMUX layer adds optional multiplayer room/control events on top. +`acp-mux` is a generic ACP multiplexer / agent mirror: one upstream stdio ACP agent process, many WebSocket clients, one shared transcript. The generic mux core stays small and provider-neutral; the Rooms layer adds optional multiplayer room/control events on top. -This file tracks where the project is going. Protocol details live in [`docs/design/amux-namespace.md`](docs/design/amux-namespace.md) and room/segment semantics live in [`docs/design/rooms.md`](docs/design/rooms.md). +The two layers are now **two crates in a Cargo workspace**, so the boundary is compiler-enforced: + +- `crates/acp-mux` (lib `acp_mux`, binary `acp-mux`) — the standalone generic 1→N mux. No `rooms/*` knowledge; does not depend on the `rooms` crate. Attaches on `?mux=`. +- `crates/rooms` (lib `rooms`, binary `rooms`) — the Rooms protocol, implemented as a `MuxExtension` plugged into the core mux actor. Depends on `acp-mux`. Attaches on `?room=`. + +This file tracks where the project is going. Protocol details live in [`docs/design/rooms-namespace.md`](docs/design/rooms-namespace.md) and room/segment semantics live in [`docs/design/rooms.md`](docs/design/rooms.md). Status legend: `[ ]` not started · `[~]` in progress · `[x]` done @@ -11,18 +16,26 @@ Status legend: `[ ]` not started · `[~]` in progress · `[x]` done - **Provider-neutral core.** Any stdio ACP agent can sit behind the mux. Provider metadata passes through; provider-private logs/metadata do not drive mux state. - **One job.** Mirror an ACP agent session into a collaborative, reconnectable room. - **Envelope-first routing.** Parse JSON-RPC envelopes and method names; keep ACP payloads as opaque `serde_json::Value` unless the method is mux-owned. -- **Layer boundary.** Core mux behavior is routing, replay, lifecycle, and safe defaults. AMUX behavior is presence, turn bookends, queue/steer/cancel controls, permission UX, and projection events. -- **Separate channels.** Agent-owned frames stay in ACP namespaces. AMUX collaboration/control facts stay in `amux/*`. +- **Layer boundary (crate-enforced).** Core mux behavior is routing, replay, lifecycle, and safe defaults, and lives in the `acp-mux` crate. Rooms behavior is presence, turn bookends, queue/steer/cancel controls, permission UX, and projection events, and lives in the `rooms` crate as a `MuxExtension`. Core cannot reference Rooms (it is not a dependency). +- **Separate channels.** Agent-owned frames stay in ACP namespaces. Rooms collaboration/control facts stay in `rooms/*`. +- **Agent channel is pure ACP.** Core owns the agent subprocess; the Rooms extension can only ask core to perform sanctioned ACP actions, never write raw bytes to the agent. This keeps the mux compatible with any standards-compliant agent. - **Fail closed on side effects.** Delegated `fs/*` and `terminal/*` client tools are blocked unless an unsafe debug flag explicitly restores raw fanout. - **No upstream protocol changes.** `acp-mux` is a consumer/proxy of ACP, not a fork of ACP or a patched agent runtime. -- **Single static binary.** Runtime dependencies should remain limited to the configured agent subprocess. +- **Static binaries.** Each binary's runtime dependencies remain limited to the configured agent subprocess. ## Current shipped shape +### Crate / layer split + +- [x] Cargo workspace with `acp-mux` (core) and `rooms` (protocol) crates; one-way dependency. +- [x] `MuxExtension` trait + `MuxCtx` capability surface; core ships a no-op extension. +- [x] Single multiplexer implementation; Rooms is an extension over it, not a fork. +- [x] Standalone `acp-mux` binary (pure 1→N, `?mux=`) and `rooms` binary (core + Rooms, `?room=`). + ### Generic ACP mux core -- [x] One agent subprocess per `?room=`. -- [x] Multiple subscribers per room. +- [x] One agent subprocess per attach key (`?mux=` for the core binary; `?room=` for the `rooms` binary). +- [x] Multiple subscribers per mux. - [x] Per-subscriber JSON-RPC id translation. - [x] Broadcast fanout for agent notifications. - [x] `initialize` and `session/new` caching for late joiners. @@ -37,17 +50,17 @@ Status legend: `[ ]` not started · `[~]` in progress · `[x]` done - [x] Provider-neutral room/session-id tracking: room id is stable, ACP `sessionId` can rotate inside it. - [x] Provider-neutral extraction: no provider-specific stderr parser, metadata interpreter, or lifecycle reason in the core path. -### AMUX collaboration layer +### Rooms collaboration layer -- [x] `amux/*` room/control namespace. -- [x] Active-turn cancellation via `amux/cancel_active_turn` → ACP `session/cancel`. -- [x] Hard steer via `amux/steer_active_turn`. -- [x] Prompt queue via `amux/queue_prompt` / `amux/unqueue_prompt`. +- [x] `rooms/*` room/control namespace. +- [x] Active-turn cancellation via `rooms/cancel_active_turn` → ACP `session/cancel`. +- [x] Hard steer via `rooms/steer_active_turn`. +- [x] Prompt queue via `rooms/queue_prompt` / `rooms/unqueue_prompt`. - [x] First-writer-wins fanout for `session/request_permission`. -- [x] Replay-safe agent request lifecycle via `amux/agent_request_opened` / `amux/agent_request_resolved`. -- [x] Optional streamed attach history via `amux/replay_started` / `amux/replay_complete`. -- [x] Segment projection frames: `amux/segment_started`, `amux/segment_ended`. -- [x] Client contract fixtures under `docs/examples/client-contract/` distinguish raw ACP passthrough from AMUX extension frames. +- [x] Replay-safe agent request lifecycle via `rooms/agent_request_opened` / `rooms/agent_request_resolved`. +- [x] Optional streamed attach history via `rooms/replay_started` / `rooms/replay_complete`. +- [x] Segment projection frames: `rooms/segment_started`, `rooms/segment_ended`. +- [x] Client contract fixtures under `docs/examples/client-contract/` distinguish raw ACP passthrough from Rooms extension frames. ## Near-term polish @@ -77,10 +90,11 @@ Default remains fail-closed. Future support should be explicit, scoped, and non- ## ACP / RFD alignment -- [ ] Track accepted shape of attach/detach lifecycle RFDs. +- [x] RFD #533 `session/attach` / `session/detach` baseline lives in the core crate (standard roster + history policies; no `rooms/*`); Rooms enrichment rides in `_meta.rooms`. +- [ ] Track accepted shape of attach/detach lifecycle RFDs; pin to the merged revision when #533 lands. - [ ] Only add proxy-owned ACP `session/update` siblings if an accepted schema and a real generic client both need them. - [ ] Track `session/resume`, `session/close`, `session/delete`, `session/fork`, and other experimental surfaces as passthrough until mux state must understand them. -- [ ] Keep `_meta.amux` additive and namespaced. +- [ ] Keep `_meta.rooms` additive and namespaced. ## Possible v1.0 scope diff --git a/crates/acp-mux/Cargo.toml b/crates/acp-mux/Cargo.toml new file mode 100644 index 0000000..5d46084 --- /dev/null +++ b/crates/acp-mux/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "acp-mux" +version = "0.1.3" +edition = "2024" + +[lib] +name = "acp_mux" +path = "src/lib.rs" + +[[bin]] +name = "acp-mux" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0.102" +axum = { version = "0.8.9", features = ["ws", "macros"] } +bytes = "1.11.1" +clap = { version = "4.6.1", features = ["derive"] } +futures = "0.3.32" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["full"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] } + +[dev-dependencies] +tokio-tungstenite = "0.29.0" diff --git a/crates/acp-mux/README.md b/crates/acp-mux/README.md new file mode 100644 index 0000000..a42bf00 --- /dev/null +++ b/crates/acp-mux/README.md @@ -0,0 +1,436 @@ +# acp-mux + +`acp-mux` is the provider-neutral core ACP multiplexer. + +It runs one stdio ACP agent process per mux id and exposes that process to one +or more WebSocket subscribers. It only understands JSON-RPC envelopes, ACP +method names needed for mux correctness, and proxy-local attach/detach methods. +It does not implement Rooms collaboration UX. + +## What It Does + +For each `?mux=`, `acp-mux`: + +1. Spawns one child process from `--agent-cmd`. +2. Treats the child process as newline-delimited JSON-RPC over stdio. +3. Accepts WebSocket subscribers at `/acp`. +4. Forwards subscriber JSON-RPC requests to the agent after rewriting the + request id to a mux-local numeric id. +5. Restores the original request id on the agent response. +6. Sends each agent response only to the subscriber that originated the request. +7. Broadcasts agent notifications to all subscribers. +8. Records broadcast frames in an in-memory replay log unless replay is + disabled. +9. Replays that broadcast log to later subscribers unless the subscriber uses + `replay=skip`. +10. Keeps one upstream session alive until the last subscriber leaves and the + mux TTL expires. + +The result is a plain 1-to-N mirror for ACP traffic: + +```text +ACP WebSocket clients -> acp-mux -> stdio ACP agent + one mux id one child process +``` + +## What It Owns + +`acp-mux` owns these behaviors: + +- WebSocket accept/close handling. +- Subscriber identity inside one mux, keyed by `peer_id`. +- Peer-id collision rejection with WebSocket close code `4409`. +- Bad query rejection with WebSocket close code `4400`. +- Agent spawn/configuration failures with WebSocket close code `1011`. +- One actor task per live mux. +- One agent subprocess per live mux. +- Subscriber detach when the WebSocket closes. +- TTL shutdown after the last subscriber leaves. +- JSON-RPC parsing at the envelope level. +- Subscriber request-id translation. +- Agent response id restoration. +- Response routing to the originating subscriber. +- Agent notification fanout. +- First-writer-wins fan-in for agent-initiated requests. +- Pending `session/request_permission` tracking. +- Subscriber `$/cancel_request` id translation. +- Agent `$/cancel_request` broadcast and pending-request cleanup. +- First successful `initialize` response caching. +- First successful `session/new` response caching. +- Successful `session/load` canonical session-id rebinding. +- In-memory broadcast replay. +- Replay metadata: sequence number, recorded timestamp, and opaque extension tag. +- Optional replay-store plumbing for library users. +- Safe default blocking for `fs/*` and `terminal/*` client-tool requests. +- Baseline proxy-local `session/attach`. +- Baseline proxy-local `session/detach`. +- Baseline `/debug/sessions`. +- Transient control-plane `session/list` through `/acp/sessions`. + +## What It Does Not Do + +`acp-mux` intentionally does not: + +- emit `rooms/*` frames; +- track Rooms rooms, turns, queues, controls, or segments; +- parse provider-specific `_meta`; +- parse provider stderr or logs; +- execute filesystem or terminal client tools; +- persist the upstream agent's conversation state; +- interpret model/tool/provider semantics; +- fabricate agent-owned `session/*` notifications; +- perform authentication or authorization; +- run a shell for `--agent-cmd`. + +The Rooms crate builds those collaboration features as an extension on top of +this core. + +## HTTP And WebSocket Surface + +### `GET /healthz` + +Returns: + +```text +ok +``` + +### `GET /acp` + +Upgrades to a WebSocket subscriber. + +Required query parameters: + +| Query | Meaning | +|---|---| +| `mux` | Mux id. Must match `[A-Za-z0-9_-]{1,128}`. Subscribers with the same mux id share one agent subprocess. | +| `peer_id` | Subscriber id. Must be unique within the mux. | + +Optional query parameters: + +| Query | Meaning | +|---|---| +| `peer_name` | Human-readable subscriber name stored in snapshots and attach roster. | +| `role` | Caller-provided role string stored in snapshots. | +| `replay=skip` | Suppresses legacy transport replay on WebSocket attach. | + +Example: + +```text +ws://127.0.0.1:8765/acp?mux=work&peer_id=desktop&peer_name=Desktop +``` + +Frames are text JSON-RPC. Binary frames are accepted if they contain UTF-8 +JSON-RPC bytes. + +### `GET /acp/sessions?cwd=` + +Runs a transient control-plane query: + +1. Spawns a fresh `--agent-cmd`. +2. Sends `initialize`. +3. Sends `session/list`, forwarding `cwd` when present. +4. Returns the agent's `result` JSON. +5. Shuts the transient process down. + +This does not create or attach to a live mux. + +### `GET /debug/sessions` + +Returns core snapshots: + +```json +{ + "muxes": [ + { + "muxId": "work", + "subscribers": [], + "pendingRequestCount": 0, + "initializeCached": true, + "cachedSessionId": "sess-123", + "canonicalSessionId": "sess-123", + "promptInFlight": null, + "replayLogLen": 4 + } + ], + "muxCount": 1 +} +``` + +Extensions can add fields to each mux snapshot through `MuxExtension`. + +## Proxy-Local `session/attach` + +`session/attach` is handled by the mux and is not forwarded to the agent. + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "session/attach", + "params": { + "sessionId": "sess-123", + "clientId": "desktop", + "historyPolicy": "full" + } +} +``` + +Core response: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "sessionId": "sess-123", + "clientId": "desktop", + "connectedClients": [ + { "clientId": "desktop", "name": "Desktop" } + ], + "historyPolicy": "full", + "history": [ + { + "method": "session/update", + "params": { + "sessionId": "sess-123", + "update": { "kind": "agent_message_chunk" } + } + } + ] + } +} +``` + +Supported `historyPolicy` values: + +| Policy | Core behavior | +|---|---| +| `full` | All broadcast replay frames as `HistoryEntry` values. | +| `full_lineage` | Same as `full` in core. Rooms gives this a segment-aware meaning. | +| `pending_only` | Unresolved permission requests only. | +| `none` | No history. | +| `after_message` | Accepted, currently falls back to `full`. | + +If `params.sessionId` is set and does not match the mux's current canonical +session id or mux id, the mux returns JSON-RPC error `-32001`. + +## Proxy-Local `session/detach` + +`session/detach` is handled by the mux and is not forwarded to the agent. + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "session/detach", + "params": { "sessionId": "sess-123" } +} +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "sessionId": "sess-123", + "status": "detached" + } +} +``` + +After sending the response, the mux removes that subscriber from the live mux. + +## Replay + +The core replay log contains only broadcast-tier frames: + +- agent notifications; +- agent `$/cancel_request` notifications; +- mux/extension broadcasts sent through the core broadcast path. + +It does not contain per-subscriber agent responses. A late subscriber does not +receive another subscriber's old request response. + +Each replay entry carries: + +- raw frame bytes; +- `recorded_at`; +- monotonic `seq`; +- opaque `ext_tag`. + +The core does not interpret `ext_tag`. Rooms uses it as a segment id. + +`--replay-turns` controls the in-memory replay log: + +| Value | Behavior | +|---|---| +| `unbounded` | Keep all broadcast frames in memory. | +| `0` | Disable replay. | +| `N > 0` | Accepted and currently treated as unbounded with a warning. | + +The core library has replay-store types and registry plumbing, but the +standalone `acp-mux` binary does not currently expose a `--replay-store` flag. +The `rooms` binary does expose replay persistence. + +## Request Routing + +Subscriber requests are forwarded like this: + +1. Parse the JSON-RPC request. +2. Allocate the next mux-local numeric id. +3. Store `{ mux_id -> peer_id, original_id, handshake }`. +4. Replace the request id with the mux id. +5. Forward the request to the agent subprocess. +6. When the agent response arrives, restore the original id. +7. Send the response only to `peer_id`. + +Notifications have no id and are broadcast to all subscribers. + +## Handshake Caching + +The mux caches successful handshakes so late subscribers do not accidentally +create duplicate upstream sessions. + +Cached: + +- first successful `initialize` result; +- first successful `session/new` result; +- successful `session/load` result, which also updates the canonical session id. + +When a later subscriber sends `initialize` or `session/new`, the mux can answer +from cache without forwarding to the agent. + +## Agent-Initiated Requests + +When the agent sends a JSON-RPC request to clients: + +1. The mux records the request id as in flight. +2. The mux broadcasts the raw request to all subscribers. +3. The first subscriber response for that id is forwarded to the agent. +4. Later duplicate responses for that id are dropped. + +`session/request_permission` requests are also tracked in `pending_permissions`. +They can be returned from `session/attach` with `historyPolicy: "pending_only"`. + +## Safety Defaults + +ACP agents may ask clients to perform local side effects with methods such as +`fs/*` and `terminal/*`. Broadcasting those requests to multiple clients can +duplicate side effects or run an action on the wrong machine. + +So the core defaults are fail-closed: + +- `initialize.params.clientCapabilities.fs` is stripped before forwarding. +- `initialize.params.clientCapabilities.terminal` is stripped before forwarding. +- Runtime `fs/*` and `terminal/*` agent requests are blocked with JSON-RPC + error `-32000`. +- Blocked client-tool requests are not broadcast and are not replayed. + +`--unsafe-debug-client-tool-broadcast` disables that protection and raw-broadcasts +those requests. Use it only for diagnostics. + +## CLI + +```sh +acp-mux \ + --agent-cmd 'cat' \ + --host 127.0.0.1 \ + --port 8765 +``` + +Flags: + +| Flag | Default | Meaning | +|---|---:|---| +| `--host` | `127.0.0.1` | HTTP/WebSocket bind address. | +| `--port` | `8765` | HTTP/WebSocket port. | +| `--agent-cmd` | none | Command and whitespace-split args used to spawn each mux's ACP agent. | +| `--mux-ttl-seconds` | `60` | Seconds to retain an empty mux before shutting down its agent. | +| `--replay-turns` | `unbounded` | In-memory replay policy. | +| `--unsafe-debug-client-tool-broadcast` | `false` | Raw-broadcast delegated `fs/*` and `terminal/*` requests. | +| `--log-level` | `info` | Logging level. `RUST_LOG` takes precedence. | + +`--agent-cmd` is split on whitespace and is not run through a shell. Put +environment variables on the `acp-mux` process itself. + +## Library Extension Seam + +The core exposes `MuxExtension` for higher-level protocols. + +The extension receives hooks around: + +- subscriber requests; +- outbound request translation; +- successful request forwarding; +- subscriber notifications; +- agent notifications; +- agent requests; +- agent responses; +- prompt settlement; +- agent-request resolution; +- canonical session-id changes; +- subscriber attach/detach; +- proxy-local `session/attach`; +- scheduled extension wakes; +- debug snapshots. + +The extension can: + +- inspect core state through `MuxCtx`; +- broadcast frames; +- send frames to one subscriber; +- write requests/notifications to the agent; +- submit a prompt through core id routing; +- set the opaque replay tag; +- schedule a wake-up message. + +The default `NoopExtension` preserves pure core behavior. + +## Run A Smoke Test + +Using `cat` as an echo agent: + +```sh +cargo run -p acp-mux -- --agent-cmd 'cat' +``` + +Connect: + +```text +ws://127.0.0.1:8765/acp?mux=echo&peer_id=a +``` + +Send: + +```json +{"jsonrpc":"2.0","id":1,"method":"initialize"} +``` + +Because `cat` echoes stdin, the mux receives the same frame back as an agent +request/notification/response depending on the JSON-RPC shape. This is useful +for transport smoke tests, not for ACP semantic tests. + +## Relationship To Rooms + +Rooms is a separate crate that composes this core via `MuxExtension`. + +Use `rooms` when you want: + +- `?room=` URLs; +- `rooms/*` collaboration frames; +- turn lifecycle; +- queue/steer/cancel controls; +- segment lineage; +- Rooms-enriched attach snapshots; +- streamed backfill; +- `--replay-store` from the binary. + +Use `acp-mux` when you want the smallest provider-neutral ACP multiplexer and +no Rooms wire extension. diff --git a/src/agent/mod.rs b/crates/acp-mux/src/agent/mod.rs similarity index 100% rename from src/agent/mod.rs rename to crates/acp-mux/src/agent/mod.rs diff --git a/src/agent/process.rs b/crates/acp-mux/src/agent/process.rs similarity index 100% rename from src/agent/process.rs rename to crates/acp-mux/src/agent/process.rs diff --git a/crates/acp-mux/src/attach.rs b/crates/acp-mux/src/attach.rs new file mode 100644 index 0000000..4dd931a --- /dev/null +++ b/crates/acp-mux/src/attach.rs @@ -0,0 +1,87 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub const METHOD_ATTACH: &str = "session/attach"; +pub const METHOD_DETACH: &str = "session/detach"; + +pub const ATTACH_ERR_NOT_FOUND: i64 = -32001; +pub const ATTACH_ERR_UNSUPPORTED: i64 = -32003; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum HistoryPolicy { + #[default] + Full, + FullLineage, + PendingOnly, + None, + AfterMessage, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AttachParams { + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub history_policy: Option, + #[serde(default)] + pub after_message_id: Option, + #[serde(default)] + pub client_id: Option, + #[serde(default)] + pub client_info: Option, + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientInfo { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachResult { + pub session_id: String, + pub client_id: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub connected_clients: Vec, + pub history_policy: HistoryPolicy, + #[serde(skip_serializing_if = "Option::is_none")] + pub history: Option>, + #[serde(flatten, skip_serializing_if = "Map::is_empty")] + pub extra: Map, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectedClient { + pub client_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HistoryEntry { + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DetachParams { + #[serde(default)] + pub session_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DetachResult { + pub session_id: String, + pub status: &'static str, +} diff --git a/crates/acp-mux/src/bin/mock_acp_core.rs b/crates/acp-mux/src/bin/mock_acp_core.rs new file mode 100644 index 0000000..1e75b54 --- /dev/null +++ b/crates/acp-mux/src/bin/mock_acp_core.rs @@ -0,0 +1,211 @@ +//! Minimal mock ACP agent used by the core conformance integration tests. +//! +//! Speaks NDJSON over stdin/stdout. Recognizes a small set of methods: +//! +//! - `initialize` → canned `result` with `protocolVersion: 1`. +//! - `session/new` → canned `sessionId` (configurable via $MOCK_ACP_SESSION_ID). +//! - `session/load` → echoes back the requested `sessionId`. +//! - `session/prompt` → emits two `session/update` notifications referencing +//! the param `sessionId`, then a response with `stopReason: "end_turn"`. +//! - anything else with an id → empty `result`. +//! +//! Env knobs: +//! +//! - `MOCK_ACP_SESSION_ID` — sessionId returned by `session/new`. +//! - `MOCK_ACP_EMIT_PERMISSION=1` — on `session/prompt`, emit an +//! agent-initiated `session/request_permission` (id 10000+counter) +//! before the updates and the response. Uses the canonical ACP wire +//! shape: `params.options[{optionId, kind, name}]` and expects a reply +//! of `result.outcome = {outcome: "selected", optionId} | {outcome: +//! "cancelled"}`. The mock does NOT block on the response; it carries +//! on so subscriber-side response handling can be tested independently +//! of agent turn timing. +//! - `MOCK_ACP_PROMPT_DELAY_MS=N` — sleep N ms before responding to +//! `session/prompt`. Lets a test keep a permission request open while a +//! second client attaches. +//! +//! Per-line behavior is logged to stderr at info level so tests can grep +//! the output if needed. The process exits when stdin closes. + +use std::env; +use std::io::{self, BufRead, BufReader, Write}; +use std::thread; +use std::time::Duration; + +use serde_json::{Value, json}; + +fn main() { + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + let mut reader = BufReader::new(stdin.lock()); + + let session_id = env::var("MOCK_ACP_SESSION_ID").unwrap_or_else(|_| "sess-mock".to_string()); + let emit_permission = env::var("MOCK_ACP_EMIT_PERMISSION") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let prompt_delay_ms = env::var("MOCK_ACP_PROMPT_DELAY_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + let mut initialize_count: u32 = 0; + let mut session_new_count: u32 = 0; + let mut prompt_count: u32 = 0; + let mut next_permission_id: u64 = 10_000; + + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => break, // EOF + Ok(_) => {} + Err(err) => { + eprintln!("mock_acp: stdin read error: {err}"); + break; + } + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let frame: Value = match serde_json::from_str(trimmed) { + Ok(v) => v, + Err(err) => { + eprintln!("mock_acp: parse error: {err}: {trimmed}"); + continue; + } + }; + + let id = frame.get("id").cloned(); + let method = frame.get("method").and_then(Value::as_str).unwrap_or(""); + + eprintln!("mock_acp: rx method={method} id={id:?}"); + + // Responses from the multiplexer (id + result/error, no method) and + // notifications without an id are observed but not acted upon. + if method.is_empty() || id.is_none() { + continue; + } + let id = id.expect("checked above"); + + match method { + "initialize" => { + initialize_count += 1; + let result = json!({ + "protocolVersion": 1, + "agentInfo": { "name": "mock-acp", "version": "0.0.1" }, + "_invocation": initialize_count, + }); + let resp = json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }); + writeln!(stdout, "{resp}").ok(); + stdout.flush().ok(); + } + "session/new" => { + session_new_count += 1; + let resp = json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "sessionId": session_id, + "_invocation": session_new_count, + }, + }); + writeln!(stdout, "{resp}").ok(); + stdout.flush().ok(); + } + "session/load" => { + let requested = frame + .get("params") + .and_then(|p| p.get("sessionId")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let resp = json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": requested, "_loaded": true }, + }); + writeln!(stdout, "{resp}").ok(); + stdout.flush().ok(); + } + "session/prompt" => { + prompt_count += 1; + let sess = frame + .get("params") + .and_then(|p| p.get("sessionId")) + .cloned() + .unwrap_or_else(|| json!(session_id)); + + if emit_permission { + next_permission_id += 1; + let perm = json!({ + "jsonrpc": "2.0", + "id": next_permission_id, + "method": "session/request_permission", + "params": { + "sessionId": sess, + "toolCall": { + "toolCallId": format!("mock-tool-{next_permission_id}"), + "title": "demo_tool", + "kind": "execute", + "status": "pending", + }, + "options": [ + { "optionId": "allow_once", "kind": "allow_once", "name": "Allow once" }, + { "optionId": "deny", "kind": "reject_once", "name": "Deny" }, + ], + }, + }); + writeln!(stdout, "{perm}").ok(); + stdout.flush().ok(); + } + + // Stream two update notifications. + for chunk in ["hello ", "world"] { + let upd = json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": sess, + "update": { + "kind": "agent_message_chunk", + "content": { "type": "text", "text": chunk }, + }, + }, + }); + writeln!(stdout, "{upd}").ok(); + } + + if prompt_delay_ms > 0 { + stdout.flush().ok(); + thread::sleep(Duration::from_millis(prompt_delay_ms)); + } + + let resp = json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "stopReason": "end_turn", + "_invocation": prompt_count, + }, + }); + writeln!(stdout, "{resp}").ok(); + stdout.flush().ok(); + } + _ => { + let resp = json!({ + "jsonrpc": "2.0", + "id": id, + "result": {}, + }); + writeln!(stdout, "{resp}").ok(); + stdout.flush().ok(); + } + } + } +} diff --git a/crates/acp-mux/src/cli.rs b/crates/acp-mux/src/cli.rs new file mode 100644 index 0000000..d96e404 --- /dev/null +++ b/crates/acp-mux/src/cli.rs @@ -0,0 +1,152 @@ +use std::net::IpAddr; + +use clap::{Parser, ValueEnum}; + +#[derive(Debug, Parser)] +#[command( + name = "acp-mux", + version, + about = "Standards-oriented ACP session multiplexer" +)] +pub struct Cli { + /// Bind address for the HTTP/WS listener. + #[arg(long, default_value = "127.0.0.1")] + pub host: IpAddr, + + /// TCP port for the HTTP/WS listener. + #[arg(long, default_value_t = 8765)] + pub port: u16, + + /// Command (and args, whitespace-separated) used to spawn an agent + /// subprocess for each new `?mux=`. + #[arg(long)] + pub agent_cmd: Option, + + /// Seconds to retain a mux after the last subscriber leaves before + /// tearing down the subprocess. + #[arg(long, default_value_t = 60)] + pub mux_ttl_seconds: u64, + + /// Replay-log policy. "unbounded" (default) keeps the full broadcast + /// log; N > 0 is currently treated as unbounded with a warning. "0" + /// disables the log entirely. + #[arg(long, default_value = "unbounded")] + pub replay_turns: ReplayTurns, + + /// UNSAFE: raw-broadcast agent-initiated fs/* and terminal/* client-tool + /// requests to every subscriber. May duplicate local side effects. + #[arg(long, default_value_t = false)] + pub unsafe_debug_client_tool_broadcast: bool, + + /// Logging verbosity. Overridden by RUST_LOG when that variable is set. + #[arg(long, value_enum, default_value_t = LogLevel::Info)] + pub log_level: LogLevel, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl LogLevel { + pub fn as_filter(&self) -> &'static str { + match self { + LogLevel::Trace => "trace", + LogLevel::Debug => "debug", + LogLevel::Info => "info", + LogLevel::Warn => "warn", + LogLevel::Error => "error", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientToolMode { + Block, + UnsafeDebug, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClientToolPolicy { + pub fs: ClientToolMode, + pub terminal: ClientToolMode, +} + +impl ClientToolPolicy { + pub fn block_by_default() -> Self { + Self { + fs: ClientToolMode::Block, + terminal: ClientToolMode::Block, + } + } + + pub fn unsafe_debug_broadcast() -> Self { + Self { + fs: ClientToolMode::UnsafeDebug, + terminal: ClientToolMode::UnsafeDebug, + } + } + + pub fn mode_for_method(&self, method: &str) -> Option { + if method.starts_with("fs/") { + Some(self.fs) + } else if method.starts_with("terminal/") { + Some(self.terminal) + } else { + None + } + } +} + +impl Default for ClientToolPolicy { + fn default() -> Self { + Self::block_by_default() + } +} + +impl Cli { + pub fn client_tool_policy(&self) -> ClientToolPolicy { + if self.unsafe_debug_client_tool_broadcast { + ClientToolPolicy::unsafe_debug_broadcast() + } else { + ClientToolPolicy::block_by_default() + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplayTurns { + Disabled, + Bounded(u32), + Unbounded, +} + +impl std::str::FromStr for ReplayTurns { + type Err = String; + + fn from_str(s: &str) -> Result { + if s.eq_ignore_ascii_case("unbounded") { + return Ok(ReplayTurns::Unbounded); + } + let n: u32 = s + .parse() + .map_err(|_| format!("expected \"unbounded\" or a non-negative integer, got {s:?}"))?; + Ok(if n == 0 { + ReplayTurns::Disabled + } else { + ReplayTurns::Bounded(n) + }) + } +} + +/// Split `--agent-cmd` into (program, args). Whitespace-only splitting; no +/// shell quote handling. Returns `None` if the string is empty after trim. +pub fn split_agent_cmd(raw: &str) -> Option<(String, Vec)> { + let mut it = raw.split_whitespace().map(str::to_string); + let prog = it.next()?; + Ok::<_, ()>((prog, it.collect())).ok() +} diff --git a/crates/acp-mux/src/extension.rs b/crates/acp-mux/src/extension.rs new file mode 100644 index 0000000..5a07b8b --- /dev/null +++ b/crates/acp-mux/src/extension.rs @@ -0,0 +1,218 @@ +use std::time::Duration; + +use bytes::Bytes; +use serde_json::Value; + +use crate::attach::{AttachParams, AttachResult}; +use crate::jsonrpc::{Id, Incoming, IncomingNotification, IncomingRequest, IncomingResponse}; +use crate::mux::{MuxCore, MuxMsg, ReplayView}; +use crate::subscriber::Subscriber; + +pub enum Disposition { + Forward, + Handled, + Reject { code: i64, message: String }, +} + +pub enum NotifyDisposition { + Passthrough, + Handled, +} + +pub enum ResolvedBy { + Peer(String), + AgentCancelled, + TurnEnded, +} + +pub struct MuxCtx<'a> { + pub(crate) core: &'a mut MuxCore, +} + +impl<'a> MuxCtx<'a> { + pub(crate) fn new(core: &'a mut MuxCore) -> Self { + Self { core } + } + + pub fn mux_id(&self) -> &str { + &self.core.mux_id + } + + pub fn agent_cwd(&self) -> &str { + &self.core.agent_cwd + } + + pub fn canonical_session_id(&self) -> Option<&str> { + self.core.canonical_session_id.as_deref() + } + + pub fn subscribers(&self) -> impl Iterator { + self.core.subscribers.values() + } + + pub fn subscriber(&self, peer_id: &str) -> Option<&Subscriber> { + self.core.subscribers.get(peer_id) + } + + pub fn replay_entries(&self) -> impl Iterator> { + self.core + .replay_log + .iter() + .flat_map(|log| log.iter()) + .map(|entry| ReplayView { + seq: entry.seq, + ext_tag: entry.ext_tag, + recorded_at: entry.recorded_at.as_str(), + frame: &entry.frame, + }) + } + + pub fn pending_permissions(&self) -> &[(Id, Bytes)] { + &self.core.pending_permissions + } + + pub fn prompt_in_flight(&self) -> Option { + self.core.prompt_in_flight + } + + pub fn pending_peer(&self, mux_id: u64) -> Option<&str> { + self.core + .pending + .get(&mux_id) + .map(|pending| pending.peer_id.as_str()) + } + + pub fn broadcast(&mut self, frame: impl Into) -> bool { + self.core.broadcast(frame.into()) + } + + pub fn send_to(&mut self, peer_id: &str, frame: Bytes) { + self.core.send_to(peer_id, frame); + } + + pub fn send_to_agent(&mut self, acp_frame: Vec) { + match Incoming::parse(&acp_frame) { + Ok(Incoming::Request(_)) | Ok(Incoming::Notification(_)) => { + self.core.agent_outbox.push(acp_frame); + } + Ok(Incoming::Response(_)) => { + tracing::debug!("extension attempted to send JSON-RPC response to agent; dropping"); + } + Err(err) => { + tracing::debug!(error = %err, "extension attempted to send invalid ACP frame; dropping"); + } + } + } + + pub fn submit_prompt(&mut self, peer_id: &str, params: Value, deliver_response: bool) -> u64 { + self.core + .submit_prompt(peer_id, params, deliver_response) + .unwrap_or(0) + } + + pub fn set_replay_tag(&mut self, tag: u64) { + self.core.replay_tag = tag; + } + + pub fn schedule_wake(&mut self, delay: Duration, payload: Vec) { + let tx = self.core.self_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(delay).await; + let _ = tx.send(MuxMsg::ExtensionWake(payload)).await; + }); + } +} + +pub trait MuxExtension: Send { + fn on_subscriber_request( + &mut self, + _ctx: &mut MuxCtx, + _peer_id: &str, + _req: &mut IncomingRequest, + ) -> Disposition { + Disposition::Forward + } + + fn on_request_translating( + &mut self, + _ctx: &mut MuxCtx, + _peer_id: &str, + _mux_id: u64, + _req: &mut IncomingRequest, + ) { + } + + fn on_request_forwarded( + &mut self, + _ctx: &mut MuxCtx, + _peer_id: &str, + _mux_id: u64, + _req: &IncomingRequest, + ) { + } + + fn on_subscriber_notification( + &mut self, + _ctx: &mut MuxCtx, + _peer_id: &str, + _notif: &IncomingNotification, + ) -> NotifyDisposition { + NotifyDisposition::Passthrough + } + + fn on_agent_notification(&mut self, _ctx: &mut MuxCtx, _notif: &IncomingNotification) {} + + fn on_agent_request(&mut self, _ctx: &mut MuxCtx, _id: &Id, _req: &IncomingRequest) {} + + fn on_agent_response(&mut self, _ctx: &mut MuxCtx, _mux_id: u64, _resp: &mut IncomingResponse) { + } + + fn on_prompt_settled(&mut self, _ctx: &mut MuxCtx, _mux_id: u64, _resp: &IncomingResponse) {} + + fn on_agent_request_resolved( + &mut self, + _ctx: &mut MuxCtx, + _id: &Id, + _by: ResolvedBy, + _resp: Option<&IncomingResponse>, + ) { + } + + fn on_canonical_session_id( + &mut self, + _ctx: &mut MuxCtx, + _old: Option<&str>, + _new: &str, + _via_load: bool, + ) { + } + + fn on_subscriber_attaching(&mut self, _ctx: &mut MuxCtx, _newcomer: &Subscriber) {} + + fn on_subscriber_attached(&mut self, _ctx: &mut MuxCtx, _peer_id: &str) {} + + fn on_subscriber_detached(&mut self, _ctx: &mut MuxCtx, _peer_id: &str) {} + + fn on_attach( + &mut self, + _ctx: &mut MuxCtx, + _peer_id: &str, + _params: &AttachParams, + _result: &mut AttachResult, + ) { + } + + fn replay_frame(&mut self, _ctx: &mut MuxCtx, entry: ReplayView<'_>) -> Option { + Some(entry.frame.clone()) + } + + fn on_wake(&mut self, _ctx: &mut MuxCtx, _payload: Vec) {} + + fn debug_snapshot(&self, _ctx: &MuxCtx) -> Value { + Value::Null + } +} + +pub struct NoopExtension; + +impl MuxExtension for NoopExtension {} diff --git a/src/protocol/jsonrpc.rs b/crates/acp-mux/src/jsonrpc.rs similarity index 99% rename from src/protocol/jsonrpc.rs rename to crates/acp-mux/src/jsonrpc.rs index 7d6968d..de02814 100644 --- a/src/protocol/jsonrpc.rs +++ b/crates/acp-mux/src/jsonrpc.rs @@ -194,7 +194,7 @@ mod tests { #[test] fn notification_without_params() { - let v = json!({ "jsonrpc": "2.0", "method": "amux/peer_left" }); + let v = json!({ "jsonrpc": "2.0", "method": "session/update" }); let inc = Incoming::from_value(v.clone()).unwrap(); assert!(matches!(inc, Incoming::Notification(_))); // Params absent stays absent (not serialized as null). diff --git a/crates/acp-mux/src/lib.rs b/crates/acp-mux/src/lib.rs new file mode 100644 index 0000000..bc93868 --- /dev/null +++ b/crates/acp-mux/src/lib.rs @@ -0,0 +1,9 @@ +pub mod agent; +pub mod attach; +pub mod cli; +pub mod extension; +pub mod jsonrpc; +pub mod mux; +pub mod replay_store; +pub mod server; +pub mod subscriber; diff --git a/crates/acp-mux/src/main.rs b/crates/acp-mux/src/main.rs new file mode 100644 index 0000000..2538e6f --- /dev/null +++ b/crates/acp-mux/src/main.rs @@ -0,0 +1,52 @@ +use std::net::SocketAddr; + +use acp_mux::cli; +use acp_mux::mux::{AgentCmd, MuxRegistry}; +use acp_mux::server; +use anyhow::{Context, Result}; +use clap::Parser; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + let cli = cli::Cli::parse(); + + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(cli.log_level.as_filter())); + tracing_subscriber::fmt().with_env_filter(filter).init(); + + let agent_cmd = cli + .agent_cmd + .as_deref() + .and_then(cli::split_agent_cmd) + .map(|(program, args)| AgentCmd { program, args }); + if agent_cmd.is_none() { + tracing::warn!( + "--agent-cmd not configured; subscriber attaches will be rejected with close 1011", + ); + } + + let client_tool_policy = cli.client_tool_policy(); + if cli.unsafe_debug_client_tool_broadcast { + tracing::warn!( + "UNSAFE: raw-broadcasting agent-initiated fs/* and terminal/* client-tool requests; side effects may duplicate", + ); + } + + let registry = MuxRegistry::new( + agent_cmd, + cli.replay_turns, + std::time::Duration::from_secs(cli.mux_ttl_seconds), + client_tool_policy, + ); + let app = server::router(server::AppState::new(registry)); + + let addr = SocketAddr::new(cli.host, cli.port); + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("bind {addr}"))?; + tracing::info!(%addr, "acp-mux listening"); + + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/crates/acp-mux/src/mux/actor.rs b/crates/acp-mux/src/mux/actor.rs new file mode 100644 index 0000000..c22fd97 --- /dev/null +++ b/crates/acp-mux/src/mux/actor.rs @@ -0,0 +1,1421 @@ +//! Per-mux actor: one ACP agent subprocess, N WebSocket subscribers. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use bytes::Bytes; +use serde_json::{Map, Value, json}; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; +use tokio::time::{Instant, sleep_until}; + +use crate::agent::process::AgentProcess; +use crate::attach::{ + self, AttachParams, AttachResult, ConnectedClient, DetachParams, DetachResult, HistoryEntry, + HistoryPolicy, +}; +use crate::cli::{ClientToolMode, ClientToolPolicy, ReplayTurns}; +use crate::extension::{ + Disposition, MuxCtx, MuxExtension, NoopExtension, NotifyDisposition, ResolvedBy, +}; +use crate::jsonrpc::{ + Id, Incoming, IncomingNotification, IncomingRequest, IncomingResponse, JsonRpcError, + JsonRpcVersion, +}; +use crate::replay_store::{ReplayStore, RoomReplayStore}; +use crate::subscriber::{OutMsg, Subscriber}; + +const MUX_QUEUE_CAPACITY: usize = 256; +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); +const FIRST_MUX_ID: u64 = 1; +const SESSION_BUSY_ERROR_CODE: i64 = -32001; +const CLIENT_TOOL_BLOCKED_ERROR_CODE: i64 = -32000; +const WS_CLOSE_AGENT_DEAD: u16 = 1011; +const CANCEL_REQUEST_METHOD: &str = "$/cancel_request"; + +pub enum MuxMsg { + Attach { + subscriber: Subscriber, + ack: oneshot::Sender>, + }, + Detach { + peer_id: String, + }, + InboundFromSubscriber { + peer_id: String, + bytes: Vec, + }, + AgentStdoutLine(Vec), + AgentStderrLine(Vec), + AgentDied, + ExtensionWake(Vec), + Snapshot { + ack: oneshot::Sender, + }, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MuxSnapshot { + pub mux_id: String, + pub agent_cwd: String, + pub subscribers: Vec, + pub pending_request_count: usize, + pub initialize_cached: bool, + pub cached_session_id: Option, + pub canonical_session_id: Option, + pub prompt_in_flight: Option, + pub subprocess_dead: bool, + pub ttl_pending: bool, + pub replay_log_len: Option, + pub next_mux_id: u64, + pub pending_agent_request_count: usize, + pub pending_permission_count: usize, + #[serde(flatten, skip_serializing_if = "Map::is_empty")] + pub extra: Map, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriberSnapshot { + pub peer_id: String, + pub peer_name: Option, + pub role: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttachError { + PeerIdInUse, +} + +#[derive(Clone)] +pub struct MuxHandle { + pub tx: mpsc::Sender, +} + +impl MuxHandle { + pub fn is_alive(&self) -> bool { + !self.tx.is_closed() + } +} + +#[derive(Debug, Clone)] +pub struct MuxOptions { + pub replay_policy: ReplayTurns, + pub mux_ttl: Duration, + pub client_tool_policy: ClientToolPolicy, + pub agent_cwd: String, + pub replay_store: Option>, +} + +#[derive(Debug, Clone)] +pub struct ReplayView<'a> { + pub seq: u64, + pub ext_tag: u64, + pub recorded_at: &'a str, + pub frame: &'a Bytes, +} + +#[derive(Debug, Clone)] +pub(crate) struct ReplayEntry { + pub(crate) frame: Bytes, + pub(crate) recorded_at: String, + pub(crate) seq: u64, + pub(crate) ext_tag: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum HandshakeKind { + Initialize, + SessionNew, + SessionLoad { loaded_session_id: String }, +} + +#[derive(Debug)] +pub(crate) struct PendingRequest { + pub(crate) peer_id: String, + original_id: Id, + handshake: Option, + deliver_response: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AgentReqState { + InFlight, + Consumed, +} + +pub struct MuxCore { + pub(crate) mux_id: String, + pub(crate) agent_cwd: String, + pub(crate) subscribers: HashMap, + next_mux_id: u64, + pub(crate) pending: HashMap, + initialize_cache: Option, + session_new_cache: Option, + pub(crate) canonical_session_id: Option, + pub(crate) replay_log: Option>, + replay_store: Option, + next_replay_seq: u64, + pub(crate) replay_tag: u64, + pub(crate) prompt_in_flight: Option, + client_tool_policy: ClientToolPolicy, + agent_pending: HashMap, + pub(crate) pending_permissions: Vec<(Id, Bytes)>, + pub(crate) agent_outbox: Vec>, + pub(crate) self_tx: mpsc::Sender, +} + +struct Mux { + core: MuxCore, + ext: Box, +} + +impl MuxCore { + fn new(mux_id: String, options: MuxOptions, self_tx: mpsc::Sender) -> Self { + let (replay_log, replay_store, next_replay_seq) = match options.replay_policy { + ReplayTurns::Disabled => (None, None, 1), + ReplayTurns::Unbounded => hydrate_replay_store(&mux_id, options.replay_store.as_ref()), + ReplayTurns::Bounded(n) => { + tracing::warn!( + bound = n, + "--replay-turns N (bounded eviction) accepted but not yet implemented; behaving as unbounded", + ); + hydrate_replay_store(&mux_id, options.replay_store.as_ref()) + } + }; + Self { + mux_id, + agent_cwd: options.agent_cwd, + subscribers: HashMap::new(), + next_mux_id: FIRST_MUX_ID, + pending: HashMap::new(), + initialize_cache: None, + session_new_cache: None, + canonical_session_id: None, + replay_log, + replay_store, + next_replay_seq, + replay_tag: 0, + prompt_in_flight: None, + client_tool_policy: options.client_tool_policy, + agent_pending: HashMap::new(), + pending_permissions: Vec::new(), + agent_outbox: Vec::new(), + self_tx, + } + } + + fn attach( + &mut self, + ext: &mut dyn MuxExtension, + subscriber: Subscriber, + ) -> Result<(), AttachError> { + if self.subscribers.contains_key(&subscriber.peer_id) { + return Err(AttachError::PeerIdInUse); + } + let suppress_legacy_replay = subscriber.suppress_legacy_replay; + let snapshot: Vec = if suppress_legacy_replay { + Vec::new() + } else { + self.replay_log + .as_ref() + .map(|log| log.iter().cloned().collect()) + .unwrap_or_default() + }; + let peer_id = subscriber.peer_id.clone(); + ext.on_subscriber_attaching(&mut MuxCtx::new(self), &subscriber); + tracing::info!( + mux = %self.mux_id, + %peer_id, + replay_frames = snapshot.len(), + suppress_legacy_replay, + "subscriber joined mux", + ); + self.subscribers.insert(peer_id.clone(), subscriber); + ext.on_subscriber_attached(&mut MuxCtx::new(self), &peer_id); + if let Some(outbound) = self + .subscribers + .get(&peer_id) + .map(|sub| sub.outbound.clone()) + { + for entry in snapshot { + let Some(frame) = ext.replay_frame( + &mut MuxCtx::new(self), + ReplayView { + seq: entry.seq, + ext_tag: entry.ext_tag, + recorded_at: entry.recorded_at.as_str(), + frame: &entry.frame, + }, + ) else { + continue; + }; + if outbound.send(OutMsg::Frame(frame)).is_err() { + tracing::debug!(%peer_id, "newcomer dropped during replay"); + break; + } + } + } + Ok(()) + } + + fn detach(&mut self, ext: &mut dyn MuxExtension, peer_id: &str) { + if self.subscribers.remove(peer_id).is_some() { + tracing::info!(mux = %self.mux_id, %peer_id, "subscriber detached"); + ext.on_subscriber_detached(&mut MuxCtx::new(self), peer_id); + } + } + + fn build_snapshot(&mut self, ext: &dyn MuxExtension, ttl_pending: bool) -> MuxSnapshot { + let subscribers = self + .subscribers + .values() + .map(|s| SubscriberSnapshot { + peer_id: s.peer_id.clone(), + peer_name: s.peer_name.clone(), + role: s.role.clone(), + }) + .collect(); + let cached_session_id = self.session_new_cache.as_ref().and_then(|v| { + v.get("sessionId") + .and_then(Value::as_str) + .map(str::to_string) + }); + let extra = match ext.debug_snapshot(&MuxCtx::new(self)) { + Value::Object(map) => map, + _ => Map::new(), + }; + MuxSnapshot { + mux_id: self.mux_id.clone(), + agent_cwd: self.agent_cwd.clone(), + subscribers, + pending_request_count: self.pending.len(), + initialize_cached: self.initialize_cache.is_some(), + cached_session_id, + canonical_session_id: self.canonical_session_id.clone(), + prompt_in_flight: self.prompt_in_flight, + subprocess_dead: false, + ttl_pending, + replay_log_len: self.replay_log.as_ref().map(VecDeque::len), + next_mux_id: self.next_mux_id, + pending_agent_request_count: self + .agent_pending + .values() + .filter(|state| matches!(state, AgentReqState::InFlight)) + .count(), + pending_permission_count: self.pending_permissions.len(), + extra, + } + } + + fn close_all_subscribers(&self, code: u16, reason: &str) { + for (peer_id, sub) in &self.subscribers { + let msg = OutMsg::Close { + code, + reason: reason.to_string(), + }; + if sub.outbound.send(msg).is_err() { + tracing::debug!(%peer_id, "subscriber already gone during close"); + } + } + } + + fn handle_inbound(&mut self, ext: &mut dyn MuxExtension, peer_id: &str, bytes: Vec) { + let frame = match Incoming::parse(&bytes) { + Ok(f) => f, + Err(err) => { + tracing::warn!( + mux = %self.mux_id, + %peer_id, + error = %err, + "invalid JSON-RPC frame from subscriber; dropping", + ); + return; + } + }; + match frame { + Incoming::Notification(notif) => { + self.handle_subscriber_notification(ext, peer_id, notif, bytes) + } + Incoming::Response(resp) => self.gate_subscriber_response(ext, peer_id, resp, bytes), + Incoming::Request(req) => self.translate_outbound_request(ext, peer_id, req), + }; + } + + fn handle_subscriber_notification( + &mut self, + ext: &mut dyn MuxExtension, + peer_id: &str, + notif: IncomingNotification, + bytes: Vec, + ) { + match notif.method.as_str() { + CANCEL_REQUEST_METHOD => { + if let Some(bytes) = self.handle_subscriber_cancel(peer_id, notif) { + self.agent_outbox.push(bytes); + } + } + _ => { + let disposition = + ext.on_subscriber_notification(&mut MuxCtx::new(self), peer_id, ¬if); + if matches!(disposition, NotifyDisposition::Passthrough) { + self.agent_outbox.push(bytes); + } + } + } + } + + fn handle_subscriber_cancel( + &mut self, + peer_id: &str, + notif: IncomingNotification, + ) -> Option> { + let original_id = match parse_cancel_request_id(notif.params.as_ref()) { + Some(id) => id, + None => { + tracing::debug!(mux = %self.mux_id, %peer_id, "invalid/null cancel id; dropping"); + return None; + } + }; + let Some(mux_id) = self.find_pending_mux_id(peer_id, &original_id) else { + tracing::debug!( + mux = %self.mux_id, + %peer_id, + id = ?original_id, + "subscriber cancel for unknown id; dropping", + ); + return None; + }; + Some(build_cancel_request(Id::Number(mux_id as i64))) + } + + fn gate_subscriber_response( + &mut self, + ext: &mut dyn MuxExtension, + peer_id: &str, + resp: IncomingResponse, + bytes: Vec, + ) { + let decision = match self.agent_pending.get_mut(&resp.id) { + Some(state @ AgentReqState::InFlight) => { + *state = AgentReqState::Consumed; + self.pending_permissions.retain(|(id, _)| id != &resp.id); + Some(true) + } + Some(AgentReqState::Consumed) => Some(false), + None => None, + }; + match decision { + Some(true) => { + tracing::debug!( + mux = %self.mux_id, + %peer_id, + id = ?resp.id, + "first reply to agent-initiated request; forwarding to agent", + ); + ext.on_agent_request_resolved( + &mut MuxCtx::new(self), + &resp.id, + ResolvedBy::Peer(peer_id.to_string()), + Some(&resp), + ); + self.agent_outbox.push(bytes); + } + Some(false) => { + tracing::debug!( + mux = %self.mux_id, + %peer_id, + id = ?resp.id, + "duplicate reply to agent-initiated request; dropping", + ); + } + None => self.agent_outbox.push(bytes), + } + } + + fn translate_outbound_request( + &mut self, + ext: &mut dyn MuxExtension, + peer_id: &str, + mut req: IncomingRequest, + ) { + match req.method.as_str() { + attach::METHOD_ATTACH => { + self.handle_attach_request(ext, peer_id, req); + return; + } + attach::METHOD_DETACH => { + self.handle_detach_request(ext, peer_id, req); + return; + } + "initialize" => { + if let Some(result) = self.initialize_cache.clone() { + self.send_result_response(peer_id, req.id, result); + return; + } + } + "session/new" => { + if let Some(result) = self.session_new_cache.clone() { + self.send_result_response(peer_id, req.id, result); + return; + } + } + _ => {} + } + + match ext.on_subscriber_request(&mut MuxCtx::new(self), peer_id, &mut req) { + Disposition::Handled => return, + Disposition::Reject { code, message } => { + self.send_error_response(peer_id, req.id, code, &message); + return; + } + Disposition::Forward => {} + } + + if req.method == "session/prompt" && self.prompt_in_flight.is_some() { + self.send_error_response(peer_id, req.id, SESSION_BUSY_ERROR_CODE, "session busy"); + return; + } + + if req.method == "initialize" { + sanitize_initialize_client_capabilities(&mut req); + } + + let mux_id = self.next_mux_id; + self.next_mux_id = self.next_mux_id.saturating_add(1); + let original_id = std::mem::replace(&mut req.id, Id::Number(mux_id as i64)); + let handshake = handshake_kind(&req); + let is_prompt = req.method == "session/prompt"; + ext.on_request_translating(&mut MuxCtx::new(self), peer_id, mux_id, &mut req); + let bytes = Incoming::Request(req.clone()) + .to_vec() + .unwrap_or_else(|err| { + tracing::error!(error = %err, "failed to serialize outbound request"); + Vec::new() + }); + if bytes.is_empty() { + return; + } + self.pending.insert( + mux_id, + PendingRequest { + peer_id: peer_id.to_string(), + original_id, + handshake, + deliver_response: true, + }, + ); + if is_prompt { + self.prompt_in_flight = Some(mux_id); + } + self.agent_outbox.push(bytes); + ext.on_request_forwarded(&mut MuxCtx::new(self), peer_id, mux_id, &req); + } + + fn handle_agent_line(&mut self, ext: &mut dyn MuxExtension, line: Vec) { + let frame = match Incoming::parse(&line) { + Ok(f) => f, + Err(err) => { + tracing::warn!( + mux = %self.mux_id, + error = %err, + "invalid JSON-RPC frame from agent; broadcasting raw", + ); + self.broadcast(Bytes::from(line)); + return; + } + }; + match frame { + Incoming::Notification(notif) => { + if notif.method == CANCEL_REQUEST_METHOD { + self.handle_agent_cancel(ext, notif, line); + } else { + // Provider-neutral canonical session-id tracking: an agent + // notification whose sessionId differs from the established + // canonical id means the agent switched its active ACP + // session. Update core's canonical id (which notifies the + // extension, e.g. to rotate segments) BEFORE broadcasting, + // so any rotation frames precede this notification in the + // transcript. + if let Some(sid) = notif + .params + .as_ref() + .and_then(|p| p.get("sessionId")) + .and_then(Value::as_str) + && self.canonical_session_id.is_some() + && self.canonical_session_id.as_deref() != Some(sid) + { + let sid = sid.to_string(); + self.set_canonical_session_id(ext, &sid, false); + } + ext.on_agent_notification(&mut MuxCtx::new(self), ¬if); + self.broadcast(Bytes::from(line)); + } + } + Incoming::Request(req) => self.handle_agent_request(ext, req, line), + Incoming::Response(resp) => { + self.route_agent_response(ext, resp); + } + } + } + + fn handle_agent_request( + &mut self, + ext: &mut dyn MuxExtension, + req: IncomingRequest, + line: Vec, + ) { + if let Some(mode) = self.client_tool_policy.mode_for_method(&req.method) + && mode == ClientToolMode::Block + { + tracing::warn!( + mux = %self.mux_id, + method = %req.method, + id = ?req.id, + "blocking agent-initiated client-tool request by policy", + ); + self.agent_outbox + .push(build_client_tool_blocked_response(req.id, &req.method)); + return; + } + + self.agent_pending + .entry(req.id.clone()) + .or_insert(AgentReqState::InFlight); + let bytes = Bytes::from(line); + if req.method == "session/request_permission" { + self.pending_permissions + .push((req.id.clone(), bytes.clone())); + } + ext.on_agent_request(&mut MuxCtx::new(self), &req.id, &req); + self.fanout(bytes); + } + + fn handle_agent_cancel( + &mut self, + ext: &mut dyn MuxExtension, + notif: IncomingNotification, + line: Vec, + ) { + let Some(request_id) = parse_cancel_request_id(notif.params.as_ref()) else { + tracing::debug!(mux = %self.mux_id, "agent cancel with invalid/null requestId; dropping"); + return; + }; + if let Some(state @ AgentReqState::InFlight) = self.agent_pending.get_mut(&request_id) { + *state = AgentReqState::Consumed; + self.pending_permissions + .retain(|(pending_id, _)| pending_id != &request_id); + } + self.broadcast(Bytes::from(line)); + ext.on_agent_request_resolved( + &mut MuxCtx::new(self), + &request_id, + ResolvedBy::AgentCancelled, + None, + ); + } + + fn route_agent_response(&mut self, ext: &mut dyn MuxExtension, mut resp: IncomingResponse) { + let mux_id = match resp.id { + Id::Number(n) if n >= 0 => n as u64, + ref other => { + tracing::debug!(mux = %self.mux_id, id = ?other, "agent response id is not a mux id; dropping"); + return; + } + }; + let Some(pending) = self.pending.remove(&mux_id) else { + tracing::debug!(mux = %self.mux_id, mux_id, "response for unknown mux id; dropping"); + return; + }; + resp.id = pending.original_id; + + if self.prompt_in_flight == Some(mux_id) { + self.prompt_in_flight = None; + self.sweep_stale_agent_pending(ext); + ext.on_prompt_settled(&mut MuxCtx::new(self), mux_id, &resp); + } + + if resp.error.is_none() + && let Some(handshake) = pending.handshake.as_ref() + { + self.apply_successful_handshake(ext, handshake, resp.result.as_ref()); + } + + ext.on_agent_response(&mut MuxCtx::new(self), mux_id, &mut resp); + + if pending.deliver_response { + let bytes = serde_json::to_vec(&resp).unwrap_or_else(|err| { + tracing::error!(error = %err, "failed to serialize restored response"); + Vec::new() + }); + if !bytes.is_empty() { + self.send_to(&pending.peer_id, Bytes::from(bytes)); + } + } + } + + fn apply_successful_handshake( + &mut self, + ext: &mut dyn MuxExtension, + handshake: &HandshakeKind, + result: Option<&Value>, + ) { + match handshake { + HandshakeKind::Initialize => { + self.initialize_cache = Some(result.cloned().unwrap_or(Value::Null)); + } + HandshakeKind::SessionNew => { + let result = result.cloned().unwrap_or(Value::Null); + if let Some(session_id) = result.get("sessionId").and_then(Value::as_str) { + self.set_canonical_session_id(ext, session_id, false); + } + self.session_new_cache = Some(result); + } + HandshakeKind::SessionLoad { loaded_session_id } => { + self.set_canonical_session_id(ext, loaded_session_id, true); + let result = result + .cloned() + .unwrap_or_else(|| json!({ "sessionId": loaded_session_id })); + self.session_new_cache = Some(result); + } + } + } + + fn set_canonical_session_id( + &mut self, + ext: &mut dyn MuxExtension, + session_id: &str, + via_load: bool, + ) { + let old = self.canonical_session_id.clone(); + if old.as_deref() == Some(session_id) { + if via_load { + ext.on_canonical_session_id( + &mut MuxCtx::new(self), + old.as_deref(), + session_id, + via_load, + ); + } + return; + } + self.canonical_session_id = Some(session_id.to_string()); + ext.on_canonical_session_id(&mut MuxCtx::new(self), old.as_deref(), session_id, via_load); + } + + fn sweep_stale_agent_pending(&mut self, ext: &mut dyn MuxExtension) { + let stale_ids: Vec = self + .agent_pending + .iter() + .filter(|(_, state)| matches!(state, AgentReqState::InFlight)) + .map(|(id, _)| id.clone()) + .collect(); + for id in stale_ids { + if let Some(state) = self.agent_pending.get_mut(&id) { + *state = AgentReqState::Consumed; + } + self.pending_permissions + .retain(|(pending_id, _)| pending_id != &id); + ext.on_agent_request_resolved(&mut MuxCtx::new(self), &id, ResolvedBy::TurnEnded, None); + } + } + + fn handle_attach_request( + &mut self, + ext: &mut dyn MuxExtension, + peer_id: &str, + req: IncomingRequest, + ) { + let params: AttachParams = req + .params + .as_ref() + .map(|v| serde_json::from_value(v.clone()).unwrap_or_default()) + .unwrap_or_default(); + let requested_policy = params.history_policy.unwrap_or_default(); + let effective_policy = match requested_policy { + HistoryPolicy::AfterMessage => { + tracing::debug!( + mux = %self.mux_id, + %peer_id, + after_message_id = ?params.after_message_id, + "session/attach after_message requested; falling back to full", + ); + HistoryPolicy::Full + } + other => other, + }; + let resolved_session_id = self + .canonical_session_id + .clone() + .or_else(|| params.session_id.clone().filter(|id| !id.is_empty())) + .unwrap_or_else(|| self.mux_id.clone()); + if let Some(requested) = params.session_id.as_deref() + && !requested.is_empty() + && requested != resolved_session_id + && requested != self.mux_id + { + self.send_error_response( + peer_id, + req.id, + attach::ATTACH_ERR_NOT_FOUND, + "session not found", + ); + return; + } + let connected_clients = self.connected_clients(); + let history = match effective_policy { + HistoryPolicy::None => None, + HistoryPolicy::Full | HistoryPolicy::FullLineage | HistoryPolicy::AfterMessage => { + Some(self.history_full()) + } + HistoryPolicy::PendingOnly => Some(self.history_pending_only()), + }; + let mut result = AttachResult { + session_id: resolved_session_id, + client_id: params + .client_id + .clone() + .unwrap_or_else(|| peer_id.to_string()), + connected_clients, + history_policy: effective_policy, + history, + extra: Default::default(), + }; + ext.on_attach(&mut MuxCtx::new(self), peer_id, ¶ms, &mut result); + match serde_json::to_value(result) { + Ok(value) => self.send_result_response(peer_id, req.id, value), + Err(err) => { + tracing::error!(error = %err, "failed to serialize session/attach result"); + self.send_error_response( + peer_id, + req.id, + attach::ATTACH_ERR_UNSUPPORTED, + "session/attach serialization failed", + ); + } + } + } + + fn handle_detach_request( + &mut self, + ext: &mut dyn MuxExtension, + peer_id: &str, + req: IncomingRequest, + ) { + let params: DetachParams = req + .params + .as_ref() + .map(|v| serde_json::from_value(v.clone()).unwrap_or_default()) + .unwrap_or_default(); + let resolved_session_id = self + .canonical_session_id + .clone() + .unwrap_or_else(|| self.mux_id.clone()); + if let Some(requested) = params.session_id.as_deref() + && !requested.is_empty() + && requested != resolved_session_id + && requested != self.mux_id + { + self.send_error_response( + peer_id, + req.id, + attach::ATTACH_ERR_NOT_FOUND, + "session not found", + ); + return; + } + let result = DetachResult { + session_id: resolved_session_id, + status: "detached", + }; + match serde_json::to_value(result) { + Ok(value) => { + self.send_result_response(peer_id, req.id, value); + self.detach(ext, peer_id); + } + Err(err) => { + tracing::error!(error = %err, "failed to serialize session/detach result"); + self.send_error_response( + peer_id, + req.id, + attach::ATTACH_ERR_UNSUPPORTED, + "session/detach serialization failed", + ); + } + } + } + + fn connected_clients(&self) -> Vec { + self.subscribers + .values() + .map(|s| ConnectedClient { + client_id: s.peer_id.clone(), + name: s.peer_name.clone(), + }) + .collect() + } + + fn history_full(&self) -> Vec { + self.replay_log + .as_ref() + .into_iter() + .flat_map(|log| log.iter()) + .filter_map(|entry| history_entry_from_frame(&entry.frame)) + .collect() + } + + fn history_pending_only(&self) -> Vec { + self.pending_permissions + .iter() + .filter_map(|(_, frame)| history_entry_from_frame(frame)) + .collect() + } + + fn find_pending_mux_id(&self, peer_id: &str, original_id: &Id) -> Option { + self.pending + .iter() + .find(|(_, pr)| pr.peer_id == peer_id && &pr.original_id == original_id) + .map(|(mux_id, _)| *mux_id) + } + + pub(crate) fn broadcast(&mut self, frame: Bytes) -> bool { + if let Some(log) = self.replay_log.as_mut() { + let recorded_at = utc_rfc3339_now(); + let seq = self.next_replay_seq; + let ext_tag = self.replay_tag; + log.push_back(ReplayEntry { + frame: frame.clone(), + recorded_at: recorded_at.clone(), + seq, + ext_tag, + }); + self.next_replay_seq = self.next_replay_seq.saturating_add(1); + if let Some(store) = &self.replay_store + && let Err(err) = store.append(seq, ext_tag, &recorded_at, &frame) + { + tracing::warn!(error = %err, "replay store: append failed; frame not persisted"); + } + } + for (peer_id, sub) in &self.subscribers { + if sub.outbound.send(OutMsg::Frame(frame.clone())).is_err() { + tracing::debug!(%peer_id, "subscriber dropped during broadcast"); + } + } + self.subscribers.is_empty() + } + + fn fanout(&mut self, frame: Bytes) { + self.subscribers.retain(|peer_id, sub| { + match sub.outbound.send(OutMsg::Frame(frame.clone())) { + Ok(()) => true, + Err(_) => { + tracing::debug!(%peer_id, "outbound channel closed; dropping subscriber"); + false + } + } + }); + } + + pub(crate) fn send_to(&self, peer_id: &str, frame: Bytes) { + let Some(sub) = self.subscribers.get(peer_id) else { + tracing::debug!(%peer_id, "target subscriber absent; dropping frame"); + return; + }; + if sub.outbound.send(OutMsg::Frame(frame)).is_err() { + tracing::debug!(%peer_id, "target subscriber dropped"); + } + } + + fn send_result_response(&self, peer_id: &str, id: Id, result: Value) { + let resp = IncomingResponse { + jsonrpc: JsonRpcVersion, + id, + result: Some(result), + error: None, + }; + if let Ok(bytes) = serde_json::to_vec(&resp) { + self.send_to(peer_id, Bytes::from(bytes)); + } + } + + fn send_error_response(&self, peer_id: &str, id: Id, code: i64, message: &str) { + let resp = IncomingResponse { + jsonrpc: JsonRpcVersion, + id, + result: None, + error: Some(JsonRpcError { + code, + message: message.to_string(), + data: None, + }), + }; + if let Ok(bytes) = serde_json::to_vec(&resp) { + self.send_to(peer_id, Bytes::from(bytes)); + } + } + + pub(crate) fn submit_prompt( + &mut self, + peer_id: &str, + params: Value, + deliver_response: bool, + ) -> Option { + if self.prompt_in_flight.is_some() { + tracing::debug!( + mux = %self.mux_id, + %peer_id, + "extension submit_prompt while prompt is in flight; dropping", + ); + return None; + } + let mux_id = self.next_mux_id; + self.next_mux_id = self.next_mux_id.saturating_add(1); + let req = IncomingRequest { + jsonrpc: JsonRpcVersion, + id: Id::Number(mux_id as i64), + method: "session/prompt".to_string(), + params: Some(params), + }; + let bytes = Incoming::Request(req).to_vec().unwrap_or_else(|err| { + tracing::error!(error = %err, "failed to serialize extension prompt"); + Vec::new() + }); + if bytes.is_empty() { + return None; + } + self.pending.insert( + mux_id, + PendingRequest { + peer_id: peer_id.to_string(), + original_id: Id::Number(mux_id as i64), + handshake: None, + deliver_response, + }, + ); + self.prompt_in_flight = Some(mux_id); + self.agent_outbox.push(bytes); + Some(mux_id) + } +} + +pub fn spawn_mux( + subscriber: Subscriber, + agent: AgentProcess, + mux_id: String, + options: MuxOptions, +) -> (MuxHandle, JoinHandle<()>) { + spawn_mux_with_extension(subscriber, agent, mux_id, options, Box::new(NoopExtension)) +} + +pub fn spawn_mux_with_extension( + subscriber: Subscriber, + mut agent: AgentProcess, + mux_id: String, + options: MuxOptions, + ext: Box, +) -> (MuxHandle, JoinHandle<()>) { + let (tx, rx) = mpsc::channel(MUX_QUEUE_CAPACITY); + if let Some(mut stdout_rx) = agent.take_stdout_rx() { + let tx_stdout = tx.clone(); + tokio::spawn(async move { + while let Some(line) = stdout_rx.recv().await { + if tx_stdout.send(MuxMsg::AgentStdoutLine(line)).await.is_err() { + return; + } + } + let _ = tx_stdout.send(MuxMsg::AgentDied).await; + }); + } + if let Some(mut stderr_rx) = agent.take_stderr_rx() { + let tx_stderr = tx.clone(); + tokio::spawn(async move { + while let Some(line) = stderr_rx.recv().await { + if tx_stderr.send(MuxMsg::AgentStderrLine(line)).await.is_err() { + return; + } + } + }); + } + + let handle = MuxHandle { tx: tx.clone() }; + let actor = tokio::spawn(async move { + run_mux(subscriber, agent, mux_id, options, ext, tx, rx).await; + }); + (handle, actor) +} + +async fn run_mux( + subscriber: Subscriber, + mut agent: AgentProcess, + mux_id: String, + options: MuxOptions, + ext: Box, + tx: mpsc::Sender, + mut rx: mpsc::Receiver, +) { + let mux_ttl = options.mux_ttl; + let mut mux = Mux { + core: MuxCore::new(mux_id, options, tx), + ext, + }; + if let Err(err) = mux.core.attach(&mut *mux.ext, subscriber) { + tracing::error!(error = ?err, "failed to attach initial subscriber"); + return; + } + + let parked_deadline = Instant::now() + Duration::from_secs(365 * 24 * 60 * 60); + let ttl_sleep = sleep_until(parked_deadline); + tokio::pin!(ttl_sleep); + let mut ttl_active = false; + + loop { + if mux.core.subscribers.is_empty() && !ttl_active { + ttl_sleep.as_mut().reset(Instant::now() + mux_ttl); + ttl_active = true; + } else if !mux.core.subscribers.is_empty() && ttl_active { + ttl_sleep.as_mut().reset(parked_deadline); + ttl_active = false; + } + + tokio::select! { + _ = &mut ttl_sleep, if ttl_active => { + tracing::info!(mux = %mux.core.mux_id, "mux ttl expired; shutting down"); + break; + } + msg = rx.recv() => { + let Some(msg) = msg else { + tracing::debug!(mux = %mux.core.mux_id, "mux channel closed"); + break; + }; + match msg { + MuxMsg::Attach { subscriber, ack } => { + let result = mux.core.attach(&mut *mux.ext, subscriber); + let _ = ack.send(result); + } + MuxMsg::Detach { peer_id } => mux.core.detach(&mut *mux.ext, &peer_id), + MuxMsg::InboundFromSubscriber { peer_id, bytes } => { + mux.core.handle_inbound(&mut *mux.ext, &peer_id, bytes); + } + MuxMsg::AgentStdoutLine(line) => { + mux.core.handle_agent_line(&mut *mux.ext, line); + } + MuxMsg::AgentStderrLine(line) => { + let text = String::from_utf8_lossy(&line); + tracing::debug!(target: "agent_stderr", mux = %mux.core.mux_id, line = %text); + } + MuxMsg::AgentDied => { + tracing::warn!(mux = %mux.core.mux_id, "agent subprocess exited"); + mux.core.close_all_subscribers(WS_CLOSE_AGENT_DEAD, "agent subprocess exited"); + break; + } + MuxMsg::ExtensionWake(payload) => { + mux.ext.on_wake(&mut MuxCtx::new(&mut mux.core), payload); + } + MuxMsg::Snapshot { ack } => { + let _ = ack.send(mux.core.build_snapshot(&*mux.ext, ttl_active)); + } + } + if let Err(err) = drain_agent_outbox(&mut mux.core, &mut agent).await { + tracing::error!(mux = %mux.core.mux_id, error = %err, "agent stdin write failed"); + mux.core.close_all_subscribers(WS_CLOSE_AGENT_DEAD, "agent stdin write failed"); + return; + } + } + } + } + + if let Err(err) = agent.shutdown(SHUTDOWN_TIMEOUT).await { + tracing::warn!(error = %err, "agent shutdown failed"); + } +} + +async fn drain_agent_outbox(core: &mut MuxCore, agent: &mut AgentProcess) -> anyhow::Result<()> { + let writes = std::mem::take(&mut core.agent_outbox); + for frame in writes { + agent.send(&frame).await?; + } + Ok(()) +} + +fn handshake_kind(req: &IncomingRequest) -> Option { + match req.method.as_str() { + "initialize" => Some(HandshakeKind::Initialize), + "session/new" => Some(HandshakeKind::SessionNew), + "session/load" => req + .params + .as_ref() + .and_then(|params| params.get("sessionId")) + .and_then(Value::as_str) + .map(|loaded_session_id| HandshakeKind::SessionLoad { + loaded_session_id: loaded_session_id.to_string(), + }), + _ => None, + } +} + +fn hydrate_replay_store( + mux_id: &str, + replay_store: Option<&Arc>, +) -> (Option>, Option, u64) { + let Some(store) = replay_store else { + return (Some(VecDeque::new()), None, 1); + }; + let mut room_store = match store.open_room(mux_id) { + Ok(store) => store, + Err(err) => { + tracing::warn!( + mux = %mux_id, + error = %err, + "replay store: open failed; continuing with in-memory replay only", + ); + return (Some(VecDeque::new()), None, 1); + } + }; + let loaded = room_store.take_loaded(); + let next_replay_seq = loaded + .iter() + .map(|record| record.seq) + .max() + .unwrap_or(0) + .saturating_add(1); + let replay_log = loaded + .into_iter() + .map(|record| ReplayEntry { + frame: record.frame_bytes(), + recorded_at: record.recorded_at, + seq: record.seq, + ext_tag: record.segment_id, + }) + .collect(); + (Some(replay_log), Some(room_store), next_replay_seq) +} + +fn parse_cancel_request_id(params: Option<&Value>) -> Option { + let id_value = params.and_then(|v| v.get("requestId"))?.clone(); + let id: Id = serde_json::from_value(id_value).ok()?; + match id { + Id::Null => None, + other => Some(other), + } +} + +fn build_cancel_request(request_id: Id) -> Vec { + #[derive(serde::Serialize)] + struct CancelParams<'a> { + #[serde(rename = "requestId")] + request_id: &'a Id, + } + #[derive(serde::Serialize)] + struct CancelFrame<'a> { + jsonrpc: &'static str, + method: &'static str, + params: CancelParams<'a>, + } + serde_json::to_vec(&CancelFrame { + jsonrpc: "2.0", + method: CANCEL_REQUEST_METHOD, + params: CancelParams { + request_id: &request_id, + }, + }) + .expect("cancel_request frame is always serializable") +} + +fn build_client_tool_blocked_response(id: Id, method: &str) -> Vec { + let resp = IncomingResponse { + jsonrpc: JsonRpcVersion, + id, + result: None, + error: Some(JsonRpcError { + code: CLIENT_TOOL_BLOCKED_ERROR_CODE, + message: format!("client tool request blocked by acp-mux policy: {method}"), + data: Some(json!({ + "reason": "client_tool_blocked", + "method": method, + "policy": "block", + })), + }), + }; + serde_json::to_vec(&resp).expect("client-tool blocked response is always serializable") +} + +fn sanitize_initialize_client_capabilities(req: &mut IncomingRequest) { + let Some(Value::Object(params)) = req.params.as_mut() else { + return; + }; + let Some(Value::Object(client_capabilities)) = params.get_mut("clientCapabilities") else { + return; + }; + client_capabilities.remove("fs"); + client_capabilities.remove("terminal"); +} + +fn history_entry_from_frame(frame: &Bytes) -> Option { + let value: Value = serde_json::from_slice(frame).ok()?; + let object = value.as_object()?; + let method = object.get("method")?.as_str()?.to_string(); + let params = object.get("params").cloned(); + Some(HistoryEntry { method, params }) +} + +fn utc_rfc3339_now() -> String { + system_time_to_rfc3339_utc(SystemTime::now()) +} + +fn system_time_to_rfc3339_utc(time: SystemTime) -> String { + let duration = time.duration_since(UNIX_EPOCH).unwrap_or_default(); + let total_secs = duration.as_secs() as i64; + let days = total_secs.div_euclid(86_400); + let secs_of_day = total_secs.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + let hour = secs_of_day / 3_600; + let minute = (secs_of_day % 3_600) / 60; + let second = secs_of_day % 60; + format!( + "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{nanos:09}Z", + nanos = duration.subsec_nanos(), + ) +} + +// Howard Hinnant's civil-from-days algorithm, with day 0 = 1970-01-01. +fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) { + let z = days_since_epoch + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let mut year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = mp + if mp < 10 { 3 } else { -9 }; + if month <= 2 { + year += 1; + } + (year, month as u32, day as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_core() -> (MuxCore, Box) { + let (tx, _rx) = mpsc::channel(1); + ( + MuxCore::new( + "mux1".to_string(), + MuxOptions { + replay_policy: ReplayTurns::Unbounded, + mux_ttl: Duration::from_secs(60), + client_tool_policy: ClientToolPolicy::default(), + agent_cwd: "/tmp".to_string(), + replay_store: None, + }, + tx, + ), + Box::new(NoopExtension), + ) + } + + fn test_subscriber(peer_id: &str) -> (Subscriber, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + ( + Subscriber::new(peer_id.to_string(), None, None, false, tx), + rx, + ) + } + + fn recv_json(rx: &mut mpsc::UnboundedReceiver) -> Value { + let msg = rx.try_recv().expect("outbound frame"); + let OutMsg::Frame(bytes) = msg else { + panic!("expected frame"); + }; + serde_json::from_slice(&bytes).expect("json frame") + } + + #[test] + fn attach_returns_core_roster_without_extension_meta() { + let (mut inner, mut ext) = test_core(); + let (sub, mut rx) = test_subscriber("p1"); + inner.attach(&mut *ext, sub).unwrap(); + + inner.handle_inbound( + &mut *ext, + "p1", + br#"{"jsonrpc":"2.0","id":1,"method":"session/attach","params":{"clientId":"client-1","historyPolicy":"full"}}"# + .to_vec(), + ); + assert!(inner.agent_outbox.is_empty()); + + let response = recv_json(&mut rx); + assert_eq!(response["id"], 1); + let result = response.get("result").expect("attach result"); + assert_eq!(result["sessionId"], "mux1"); + assert_eq!(result["clientId"], "client-1"); + assert_eq!(result["connectedClients"][0]["clientId"], "p1"); + assert!(result.get("_meta").is_none()); + } + + #[test] + fn agent_initiated_request_is_first_writer_wins() { + let (mut inner, mut ext) = test_core(); + let (sub1, mut rx1) = test_subscriber("p1"); + let (sub2, mut rx2) = test_subscriber("p2"); + inner.attach(&mut *ext, sub1).unwrap(); + inner.attach(&mut *ext, sub2).unwrap(); + + inner.handle_agent_line( + &mut *ext, + br#"{"jsonrpc":"2.0","id":"perm-1","method":"session/request_permission","params":{"toolName":"edit"}}"# + .to_vec(), + ); + let _ = recv_json(&mut rx1); + let _ = recv_json(&mut rx2); + + inner.handle_inbound( + &mut *ext, + "p1", + br#"{"jsonrpc":"2.0","id":"perm-1","result":{"outcome":"approved"}}"#.to_vec(), + ); + assert_eq!(inner.agent_outbox.len(), 1); + + inner.agent_outbox.clear(); + inner.handle_inbound( + &mut *ext, + "p2", + br#"{"jsonrpc":"2.0","id":"perm-1","result":{"outcome":"approved"}}"#.to_vec(), + ); + assert!(inner.agent_outbox.is_empty()); + } + + #[test] + fn concurrent_prompt_is_rejected_locally() { + let (mut inner, mut ext) = test_core(); + let (sub1, _rx1) = test_subscriber("p1"); + let (sub2, mut rx2) = test_subscriber("p2"); + inner.attach(&mut *ext, sub1).unwrap(); + inner.attach(&mut *ext, sub2).unwrap(); + + inner.handle_inbound( + &mut *ext, + "p1", + br#"{"jsonrpc":"2.0","id":10,"method":"session/prompt","params":{"sessionId":"s1","prompt":[{"type":"text","text":"hi"}]}}"# + .to_vec(), + ); + assert_eq!(inner.agent_outbox.len(), 1); + inner.agent_outbox.clear(); + + inner.handle_inbound( + &mut *ext, + "p2", + br#"{"jsonrpc":"2.0","id":11,"method":"session/prompt","params":{"sessionId":"s1","prompt":[{"type":"text","text":"again"}]}}"# + .to_vec(), + ); + assert!(inner.agent_outbox.is_empty()); + + let response = recv_json(&mut rx2); + assert_eq!(response["id"], 11); + assert_eq!(response["error"]["code"], SESSION_BUSY_ERROR_CODE); + } +} diff --git a/crates/acp-mux/src/mux/mod.rs b/crates/acp-mux/src/mux/mod.rs new file mode 100644 index 0000000..295b074 --- /dev/null +++ b/crates/acp-mux/src/mux/mod.rs @@ -0,0 +1,8 @@ +mod actor; +pub mod registry; + +pub use actor::{ + AttachError, MuxCore, MuxHandle, MuxMsg, MuxOptions, MuxSnapshot, ReplayView, + SubscriberSnapshot, spawn_mux, spawn_mux_with_extension, +}; +pub use registry::{AgentCmd, ControlPlaneSessionListError, MuxRegistry, RegistryError}; diff --git a/crates/acp-mux/src/mux/registry.rs b/crates/acp-mux/src/mux/registry.rs new file mode 100644 index 0000000..21f48c2 --- /dev/null +++ b/crates/acp-mux/src/mux/registry.rs @@ -0,0 +1,355 @@ +//! Mux registry: maps mux ids to live mux actors. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{Value, json}; +use thiserror::Error; +use tokio::sync::{Mutex, oneshot}; +use tokio::time::timeout; + +use crate::agent::process::AgentProcess; +use crate::cli::{ClientToolPolicy, ReplayTurns}; +use crate::extension::{MuxExtension, NoopExtension}; +use crate::jsonrpc::{Id, Incoming, JsonRpcError, ParseError}; +use crate::mux::actor::{ + AttachError, MuxHandle, MuxMsg, MuxOptions, MuxSnapshot, spawn_mux_with_extension, +}; +use crate::replay_store::ReplayStore; +use crate::subscriber::Subscriber; + +const CONTROL_PLANE_AGENT_TIMEOUT: Duration = Duration::from_secs(8); + +#[derive(Debug, Clone)] +pub struct AgentCmd { + pub program: String, + pub args: Vec, +} + +#[derive(Debug, Error)] +pub enum RegistryError { + #[error("server has no --agent-cmd configured")] + AgentCmdMissing, + #[error("peer_id already attached to this mux")] + PeerIdInUse, + #[error("agent spawn failed: {0}")] + AgentSpawn(#[from] anyhow::Error), + #[error("mux actor not reachable")] + ActorUnreachable, +} + +#[derive(Debug, Error)] +pub enum ControlPlaneSessionListError { + #[error("agent command not configured")] + AgentCmdMissing, + #[error("agent process failed: {0}")] + AgentProcess(#[from] anyhow::Error), + #[error("json encode/decode failed: {0}")] + Json(#[from] serde_json::Error), + #[error("agent protocol parse failed: {0}")] + Protocol(#[from] ParseError), + #[error("agent returned JSON-RPC error {code}: {message}")] + AgentJsonRpc { + code: i64, + message: String, + data: Option, + }, + #[error("agent did not respond to {method} before timeout")] + AgentTimeout { method: &'static str }, + #[error("agent exited before responding to {method}")] + AgentEof { method: &'static str }, +} + +impl From for ControlPlaneSessionListError { + fn from(err: JsonRpcError) -> Self { + Self::AgentJsonRpc { + code: err.code, + message: err.message, + data: err.data, + } + } +} + +pub struct MuxRegistry { + agent_cmd: Option, + replay_policy: ReplayTurns, + mux_ttl: Duration, + client_tool_policy: ClientToolPolicy, + replay_store: Option>, + extension_factory: Arc Box + Send + Sync>, + muxes: Mutex>, +} + +impl MuxRegistry { + pub fn new( + agent_cmd: Option, + replay_policy: ReplayTurns, + mux_ttl: Duration, + client_tool_policy: ClientToolPolicy, + ) -> Arc { + Self::with_extension( + agent_cmd, + replay_policy, + mux_ttl, + client_tool_policy, + || Box::new(NoopExtension), + ) + } + + pub fn with_extension( + agent_cmd: Option, + replay_policy: ReplayTurns, + mux_ttl: Duration, + client_tool_policy: ClientToolPolicy, + extension_factory: F, + ) -> Arc + where + F: Fn() -> Box + Send + Sync + 'static, + { + Self::with_extension_and_replay_store( + agent_cmd, + replay_policy, + mux_ttl, + client_tool_policy, + None, + extension_factory, + ) + } + + pub fn with_extension_and_replay_store( + agent_cmd: Option, + replay_policy: ReplayTurns, + mux_ttl: Duration, + client_tool_policy: ClientToolPolicy, + replay_store: Option>, + extension_factory: F, + ) -> Arc + where + F: Fn() -> Box + Send + Sync + 'static, + { + Arc::new(Self { + agent_cmd, + replay_policy, + mux_ttl, + client_tool_policy, + replay_store, + extension_factory: Arc::new(extension_factory), + muxes: Mutex::new(HashMap::new()), + }) + } + + pub async fn list_sessions_control_plane( + &self, + cwd: Option, + ) -> Result { + let cmd = self + .agent_cmd + .clone() + .ok_or(ControlPlaneSessionListError::AgentCmdMissing)?; + let mut agent = AgentProcess::spawn(&cmd.program, &cmd.args).await?; + if let Some(mut stderr_rx) = agent.take_stderr_rx() { + tokio::spawn(async move { + while let Some(line) = stderr_rx.recv().await { + let text = String::from_utf8_lossy(&line); + tracing::debug!(target: "agent_stderr", control_plane = true, line = %text); + } + }); + } + let result = query_transient_session_list(&mut agent, cwd).await; + if let Err(err) = agent.shutdown(CONTROL_PLANE_AGENT_TIMEOUT).await { + tracing::warn!(error = %err, "transient session/list agent shutdown failed"); + } + result + } + + pub async fn attach( + self: &Arc, + mux_id: &str, + subscriber: Subscriber, + ) -> Result { + let existing = { + let mut muxes = self.muxes.lock().await; + match muxes.get(mux_id) { + Some(h) if h.is_alive() => Some(h.clone()), + Some(_) => { + muxes.remove(mux_id); + None + } + None => None, + } + }; + + if let Some(handle) = existing { + self.try_join(&handle, subscriber).await?; + return Ok(handle); + } + + let mut muxes = self.muxes.lock().await; + if let Some(h) = muxes.get(mux_id) { + if h.is_alive() { + let handle = h.clone(); + drop(muxes); + self.try_join(&handle, subscriber).await?; + return Ok(handle); + } + muxes.remove(mux_id); + } + self.spawn_locked(&mut muxes, mux_id, subscriber).await + } + + async fn spawn_locked( + self: &Arc, + muxes: &mut HashMap, + mux_id: &str, + subscriber: Subscriber, + ) -> Result { + let cmd = self + .agent_cmd + .as_ref() + .ok_or(RegistryError::AgentCmdMissing)?; + let agent_cwd = std::env::current_dir() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|err| { + tracing::warn!(error = %err, "failed to read current dir for mux context"); + String::new() + }); + let agent = AgentProcess::spawn(&cmd.program, &cmd.args) + .await + .map_err(RegistryError::AgentSpawn)?; + let (handle, _actor) = spawn_mux_with_extension( + subscriber, + agent, + mux_id.to_string(), + MuxOptions { + replay_policy: self.replay_policy, + mux_ttl: self.mux_ttl, + client_tool_policy: self.client_tool_policy, + agent_cwd, + replay_store: self.replay_store.clone(), + }, + (self.extension_factory)(), + ); + muxes.insert(mux_id.to_string(), handle.clone()); + tracing::info!(mux = %mux_id, "spawned mux"); + Ok(handle) + } + + async fn try_join( + &self, + handle: &MuxHandle, + subscriber: Subscriber, + ) -> Result<(), RegistryError> { + let (ack_tx, ack_rx) = oneshot::channel(); + handle + .tx + .send(MuxMsg::Attach { + subscriber, + ack: ack_tx, + }) + .await + .map_err(|_| RegistryError::ActorUnreachable)?; + match ack_rx.await { + Ok(Ok(())) => Ok(()), + Ok(Err(AttachError::PeerIdInUse)) => Err(RegistryError::PeerIdInUse), + Err(_) => Err(RegistryError::ActorUnreachable), + } + } + + pub async fn snapshot(&self) -> Vec { + let handles: Vec<(String, MuxHandle)> = { + let muxes = self.muxes.lock().await; + muxes + .iter() + .filter(|(_, h)| h.is_alive()) + .map(|(id, h)| (id.clone(), h.clone())) + .collect() + }; + + let mut out = Vec::with_capacity(handles.len()); + for (id, handle) in handles { + let (ack_tx, ack_rx) = oneshot::channel(); + if handle + .tx + .send(MuxMsg::Snapshot { ack: ack_tx }) + .await + .is_err() + { + tracing::debug!(mux = %id, "mux unreachable during snapshot"); + continue; + } + match tokio::time::timeout(Duration::from_millis(200), ack_rx).await { + Ok(Ok(snap)) => out.push(snap), + Ok(Err(_)) => tracing::debug!(mux = %id, "mux actor dropped snapshot ack"), + Err(_) => tracing::warn!(mux = %id, "snapshot timed out"), + } + } + out + } + + pub async fn shutdown(&self) { + let mut muxes = self.muxes.lock().await; + let count = muxes.len(); + muxes.clear(); + tracing::info!(muxes = count, "registry shutdown"); + } + + pub async fn live_mux_count(&self) -> usize { + let muxes = self.muxes.lock().await; + muxes.values().filter(|h| h.is_alive()).count() + } +} + +async fn query_transient_session_list( + agent: &mut AgentProcess, + cwd: Option, +) -> Result { + let _initialize = request_transient_agent( + agent, + 1, + "initialize", + Some(json!({ "protocolVersion": 1 })), + ) + .await?; + + let params = cwd.map(|cwd| json!({ "cwd": cwd })); + request_transient_agent(agent, 2, "session/list", params).await +} + +async fn request_transient_agent( + agent: &mut AgentProcess, + id: i64, + method: &'static str, + params: Option, +) -> Result { + let mut request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + }); + if let Some(params) = params { + request["params"] = params; + } + let bytes = serde_json::to_vec(&request)?; + agent.send(&bytes).await?; + + for _ in 0..128 { + let line = timeout(CONTROL_PLANE_AGENT_TIMEOUT, agent.recv_line()) + .await + .map_err(|_| ControlPlaneSessionListError::AgentTimeout { method })? + .ok_or(ControlPlaneSessionListError::AgentEof { method })?; + let incoming = Incoming::parse(&line)?; + let Incoming::Response(response) = incoming else { + continue; + }; + if response.id != Id::Number(id) { + continue; + } + if let Some(error) = response.error { + return Err(error.into()); + } + return Ok(response.result.unwrap_or(Value::Null)); + } + + Err(ControlPlaneSessionListError::AgentTimeout { method }) +} diff --git a/src/room/replay_store.rs b/crates/acp-mux/src/replay_store.rs similarity index 92% rename from src/room/replay_store.rs rename to crates/acp-mux/src/replay_store.rs index b739a17..1e05e64 100644 --- a/src/room/replay_store.rs +++ b/crates/acp-mux/src/replay_store.rs @@ -1,8 +1,8 @@ -//! Opt-in on-disk persistence for the per-room broadcast replay log. +//! Opt-in on-disk persistence for the per-mux broadcast replay log. //! -//! Enabled by `--replay-store `. One JSONL file per room -//! (`/.jsonl`); every persisted line is one broadcast-tier -//! frame in the same order it flowed through `RoomInner::broadcast`. +//! Enabled by higher-level crates that want persistence. One JSONL file per +//! mux (`/.jsonl`); every persisted line is one broadcast-tier +//! frame in the same order it flowed through the mux broadcast path. //! //! Record schema (v=1): //! @@ -11,20 +11,20 @@ //! ``` //! //! - `seq` and `segment_id` are the same values carried by the in-memory -//! `ReplayEntry`. `segment_id` of 0 means the pre-segment sentinel. +//! replay entry. Core treats `segment_id` as an opaque extension tag. //! - `frame` is the raw broadcast frame as a JSON value (frames are //! already valid JSON-RPC). Replay metadata (`recordedAt`, `replaySeq`) //! is *not* baked into `frame` here; it is re-injected on read by -//! `ReplayEntry::frame_for_replay()` using the persisted `recorded_at` -//! and `seq`, preserving the contract that mux-recorded time wins. +//! the persisted `recorded_at` and `seq`, preserving the contract that +//! mux-recorded time wins. //! -//! Concurrency: each room owns its own `RoomReplayStore` and the room -//! actor is single-threaded, so writes are serialized by construction. +//! Concurrency: each mux owns its own `RoomReplayStore` and the mux actor +//! is single-threaded, so writes are serialized by construction. //! The file is opened with `O_APPEND`, which keeps each in-process //! write positioned at the current end of file. We do not coordinate //! cross-process access and do not rely on any filesystem-level //! atomicity guarantee for concurrent writers — operators running two -//! `amux` instances against the same store directory is a +//! two mux processes against the same store directory is a //! configuration error. use std::fs::{File, OpenOptions}; @@ -279,7 +279,7 @@ mod tests { fn tempdir() -> PathBuf { let base = std::env::temp_dir(); let suffix = format!( - "amux-replay-test-{}-{}", + "acp-mux-replay-test-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/crates/acp-mux/src/server.rs b/crates/acp-mux/src/server.rs new file mode 100644 index 0000000..190aafb --- /dev/null +++ b/crates/acp-mux/src/server.rs @@ -0,0 +1,348 @@ +//! HTTP + WebSocket surface for the standalone ACP mux. + +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::{ + Query, State, WebSocketUpgrade, + ws::{CloseFrame, Message, Utf8Bytes, WebSocket}, + }, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; +use futures::stream::{SplitSink, SplitStream}; +use futures::{SinkExt, StreamExt}; +use serde::Deserialize; +use serde::Serialize; +use tokio::sync::mpsc; + +use crate::mux::registry::{ControlPlaneSessionListError, MuxRegistry, RegistryError}; +use crate::mux::{MuxMsg, MuxSnapshot}; +use crate::subscriber::{OutMsg, Subscriber}; + +const MUX_ID_MAX_LEN: usize = 128; + +pub const CLOSE_CODE_BAD_QUERY: u16 = 4400; +pub const CLOSE_CODE_PEER_CONFLICT: u16 = 4409; +pub const CLOSE_CODE_INTERNAL: u16 = 1011; + +#[derive(Clone)] +pub struct AppState { + pub registry: Arc, +} + +impl AppState { + pub fn new(registry: Arc) -> Self { + Self { registry } + } +} + +#[derive(Debug, Deserialize)] +pub struct AttachQuery { + pub mux: Option, + pub peer_id: Option, + pub peer_name: Option, + pub role: Option, + pub replay: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SessionDiscoveryQuery { + pub cwd: Option, +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/healthz", get(healthz)) + .route("/acp", get(acp_attach)) + .route("/acp/sessions", get(acp_sessions)) + .route("/debug/sessions", get(debug_sessions)) + .with_state(state) +} + +async fn healthz() -> &'static str { + "ok\n" +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DebugSessionsResponse { + muxes: Vec, + mux_count: usize, +} + +async fn debug_sessions(State(state): State) -> impl IntoResponse { + let muxes = state.registry.snapshot().await; + let count = muxes.len(); + Json(DebugSessionsResponse { + muxes, + mux_count: count, + }) +} + +#[derive(Serialize)] +struct ErrorResponse { + error: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + details: Option, +} + +async fn acp_sessions( + State(state): State, + Query(q): Query, +) -> Response { + match state.registry.list_sessions_control_plane(q.cwd).await { + Ok(result) => (StatusCode::OK, Json(result)).into_response(), + Err(err) => control_plane_error_response(err), + } +} + +fn control_plane_error_response(err: ControlPlaneSessionListError) -> Response { + let (status, error, details) = match err { + ControlPlaneSessionListError::AgentCmdMissing => ( + StatusCode::SERVICE_UNAVAILABLE, + "agent command not configured", + None, + ), + other => ( + StatusCode::BAD_GATEWAY, + "agent session/list failed", + Some(other.to_string()), + ), + }; + (status, Json(ErrorResponse { error, details })).into_response() +} + +async fn acp_attach( + State(state): State, + Query(q): Query, + ws: WebSocketUpgrade, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_attach(state, q, socket)) +} + +async fn handle_attach(state: AppState, q: AttachQuery, mut socket: WebSocket) { + let validated = match validate(&q) { + Ok(v) => v, + Err(reason) => { + tracing::warn!(reason, "rejecting WS upgrade: bad query"); + close_with(&mut socket, CLOSE_CODE_BAD_QUERY, reason).await; + return; + } + }; + let ValidatedAttach { + mux, + peer_id, + peer_name, + role, + skip_legacy_replay, + } = validated; + + let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::(); + let subscriber = Subscriber::new( + peer_id.clone(), + peer_name, + role, + skip_legacy_replay, + outbound_tx, + ); + + let handle = match state.registry.attach(&mux, subscriber).await { + Ok(h) => h, + Err(RegistryError::PeerIdInUse) => { + tracing::warn!(%mux, %peer_id, "peer_id collision"); + close_with( + &mut socket, + CLOSE_CODE_PEER_CONFLICT, + "peer_id already attached to this mux", + ) + .await; + return; + } + Err(RegistryError::AgentCmdMissing) => { + tracing::error!("attach refused: --agent-cmd not configured"); + close_with( + &mut socket, + CLOSE_CODE_INTERNAL, + "server has no --agent-cmd configured", + ) + .await; + return; + } + Err(RegistryError::AgentSpawn(err)) => { + tracing::error!(error = %err, "agent spawn failed"); + close_with(&mut socket, CLOSE_CODE_INTERNAL, "agent spawn failed").await; + return; + } + Err(RegistryError::ActorUnreachable) => { + tracing::error!("mux actor unreachable mid-attach"); + close_with(&mut socket, CLOSE_CODE_INTERNAL, "mux unreachable").await; + return; + } + }; + + tracing::info!(%mux, %peer_id, "subscriber attached"); + + let (ws_sink, ws_stream) = socket.split(); + let in_mux_tx = handle.tx.clone(); + let in_peer_id = peer_id.clone(); + let out_peer_id = peer_id.clone(); + let in_mux = mux.clone(); + let out_mux = mux.clone(); + + tokio::select! { + _ = ws_in_task(ws_stream, in_peer_id, in_mux_tx, in_mux) => {}, + _ = ws_out_task(ws_sink, outbound_rx, out_peer_id, out_mux) => {}, + } + + let _ = handle + .tx + .send(MuxMsg::Detach { + peer_id: peer_id.clone(), + }) + .await; + tracing::debug!(%mux, %peer_id, "ws handler exiting"); +} + +async fn ws_in_task( + mut ws_stream: SplitStream, + peer_id: String, + mux_tx: mpsc::Sender, + mux: String, +) { + while let Some(msg) = ws_stream.next().await { + match msg { + Ok(Message::Text(t)) => { + let bytes = strip_trailing_newline(t.as_bytes()); + if mux_tx + .send(MuxMsg::InboundFromSubscriber { + peer_id: peer_id.clone(), + bytes, + }) + .await + .is_err() + { + tracing::debug!(%mux, %peer_id, "mux actor gone; ws_in exiting"); + return; + } + } + Ok(Message::Binary(b)) => { + let bytes = strip_trailing_newline(&b); + if mux_tx + .send(MuxMsg::InboundFromSubscriber { + peer_id: peer_id.clone(), + bytes, + }) + .await + .is_err() + { + return; + } + } + Ok(Message::Close(_)) => { + tracing::debug!(%mux, %peer_id, "ws_in: client close"); + return; + } + Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {} + Err(err) => { + tracing::debug!(%mux, %peer_id, error = %err, "ws recv error"); + return; + } + } + } +} + +async fn ws_out_task( + mut ws_sink: SplitSink, + mut outbound_rx: mpsc::UnboundedReceiver, + peer_id: String, + mux: String, +) { + while let Some(msg) = outbound_rx.recv().await { + match msg { + OutMsg::Frame(bytes) => match Utf8Bytes::try_from(bytes) { + Ok(text) => { + if ws_sink.send(Message::Text(text)).await.is_err() { + tracing::debug!(%mux, %peer_id, "ws_out: peer dropped"); + return; + } + } + Err(err) => { + tracing::warn!(%mux, %peer_id, error = %err, "non-UTF8 agent stdout line; dropped"); + } + }, + OutMsg::Close { code, reason } => { + tracing::info!(%mux, %peer_id, code, %reason, "ws_out: structured close"); + let _ = ws_sink + .send(Message::Close(Some(CloseFrame { + code, + reason: reason.into(), + }))) + .await; + return; + } + } + } + tracing::debug!(%mux, %peer_id, "ws_out: outbound channel closed"); + let _ = ws_sink.close().await; +} + +struct ValidatedAttach { + mux: String, + peer_id: String, + peer_name: Option, + role: Option, + skip_legacy_replay: bool, +} + +fn validate(q: &AttachQuery) -> Result { + let mux = q.mux.as_deref().ok_or("missing ?mux")?; + if !is_valid_mux_id(mux) { + return Err("invalid ?mux (expect ^[A-Za-z0-9_-]{1,128}$)"); + } + let peer_id = q.peer_id.as_deref().ok_or("missing ?peer_id")?; + if peer_id.is_empty() { + return Err("empty ?peer_id"); + } + let skip_legacy_replay = match q.replay.as_deref() { + None => false, + Some("skip") => true, + Some(_) => return Err("invalid ?replay (expected 'skip')"), + }; + Ok(ValidatedAttach { + mux: mux.to_string(), + peer_id: peer_id.to_string(), + peer_name: q.peer_name.clone(), + role: q.role.clone(), + skip_legacy_replay, + }) +} + +fn strip_trailing_newline(bytes: &[u8]) -> Vec { + let mut out = bytes.to_vec(); + if out.ends_with(b"\n") { + out.pop(); + if out.ends_with(b"\r") { + out.pop(); + } + } + out +} + +pub fn is_valid_mux_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= MUX_ID_MAX_LEN + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} + +async fn close_with(socket: &mut WebSocket, code: u16, reason: &str) { + let _ = socket + .send(Message::Close(Some(CloseFrame { + code, + reason: reason.to_string().into(), + }))) + .await; +} diff --git a/src/multiplex/subscriber.rs b/crates/acp-mux/src/subscriber.rs similarity index 100% rename from src/multiplex/subscriber.rs rename to crates/acp-mux/src/subscriber.rs diff --git a/crates/acp-mux/tests/conformance.rs b/crates/acp-mux/tests/conformance.rs new file mode 100644 index 0000000..57db525 --- /dev/null +++ b/crates/acp-mux/tests/conformance.rs @@ -0,0 +1,530 @@ +//! RFD-#533 conformance tests for the standalone core multiplexer. +//! +//! These spawn a real core `acp-mux` server (`acp_mux::server::router`), +//! drive it over WebSocket using `tokio-tungstenite`, and use the +//! `mock_acp_core` binary as the agent subprocess. Cargo sets +//! `CARGO_BIN_EXE_mock_acp_core` automatically for integration tests under +//! `tests/` and builds the bin as a dependency of this test crate. +//! (The binary is named `mock_acp_core` rather than `mock_acp` so it does +//! not collide with the `rooms` crate's `mock_acp` in the shared target dir.) +//! +//! The core attaches with the `?mux=` query param (NOT `?room=`) and +//! must NEVER emit any `rooms/*` frame. The invariant test below asserts +//! that explicitly across every frame any client receives. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use acp_mux::cli::{ClientToolPolicy, ReplayTurns}; +use acp_mux::mux::registry::{AgentCmd, MuxRegistry}; +use acp_mux::server::{AppState, router}; +use futures::{SinkExt, StreamExt}; +use tokio::time::timeout; +use tokio_tungstenite::tungstenite::Message as ClientMsg; + +type WsStream = + tokio_tungstenite::WebSocketStream>; + +/// Short TTL so teardown is observable inside a normal test budget. +const TEST_DEFAULT_TTL: Duration = Duration::from_millis(150); + +fn mock_acp_path() -> String { + env!("CARGO_BIN_EXE_mock_acp_core").to_string() +} + +fn mock_agent_cmd() -> AgentCmd { + AgentCmd { + program: mock_acp_path(), + args: vec![], + } +} + +async fn spawn_server(agent_cmd: Option) -> (SocketAddr, Arc) { + let registry = MuxRegistry::new( + agent_cmd, + ReplayTurns::Unbounded, + TEST_DEFAULT_TTL, + ClientToolPolicy::default(), + ); + let app = router(AppState::new(registry.clone())); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + tokio::time::sleep(Duration::from_millis(20)).await; + (addr, registry) +} + +async fn spawn_server_with_mock() -> (SocketAddr, Arc) { + spawn_server(Some(mock_agent_cmd())).await +} + +async fn spawn_server_with_mock_env(vars: &[(&str, &str)]) -> (SocketAddr, Arc) { + for (k, v) in vars { + // Safety: tests run in-process; the mock_acp subprocess inherits + // these via the spawned agent. Set before the registry spawns any + // agent. Single-threaded test bodies, no concurrent env mutation. + unsafe { std::env::set_var(k, v) }; + } + spawn_server_with_mock().await +} + +async fn connect(addr: SocketAddr, query: &str) -> WsStream { + let url = format!("ws://{addr}/acp?{query}"); + let (ws, _) = tokio_tungstenite::connect_async(url) + .await + .expect("ws connect"); + ws +} + +/// Send a JSON-RPC request, skip notifications / unrelated frames, and +/// return the response matching the request's id. Asserts the invariant +/// that no frame seen along the way carries a `rooms/*` method. +async fn ws_request(ws: &mut WsStream, payload: &str) -> serde_json::Value { + let req: serde_json::Value = serde_json::from_str(payload).expect("payload is JSON"); + let req_id = req.get("id").cloned(); + ws.send(ClientMsg::Text(payload.into())).await.unwrap(); + loop { + let msg = timeout(Duration::from_secs(2), ws.next()) + .await + .expect("ws recv timeout") + .expect("stream ended") + .expect("recv err"); + let ClientMsg::Text(t) = msg else { + continue; + }; + let v: serde_json::Value = serde_json::from_str(t.as_str()).expect("frame is JSON"); + assert_no_rooms_frame(&v); + // Skip notifications and unrelated requests (anything with a method). + if v.get("method").is_some() { + continue; + } + if v.get("id") == req_id.as_ref() { + return v; + } + } +} + +/// Wait for the next frame carrying the given `method`, asserting the +/// no-`rooms/*` invariant on every frame observed in the meantime. +async fn ws_next_method(ws: &mut WsStream, method: &str) -> serde_json::Value { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let Ok(Some(Ok(ClientMsg::Text(t)))) = timeout(Duration::from_millis(100), ws.next()).await + else { + continue; + }; + let v: serde_json::Value = serde_json::from_str(t.as_str()).expect("frame is JSON"); + assert_no_rooms_frame(&v); + if v.get("method") == Some(&serde_json::json!(method)) { + return v; + } + } + panic!("timed out waiting for method {method}"); +} + +/// Collect every text frame received within `dur`, asserting the +/// no-`rooms/*` invariant on each. +async fn drain_for(ws: &mut WsStream, dur: Duration) -> Vec { + let mut out = Vec::new(); + let deadline = std::time::Instant::now() + dur; + while std::time::Instant::now() < deadline { + let Ok(next) = timeout(Duration::from_millis(50), ws.next()).await else { + continue; + }; + match next { + Some(Ok(ClientMsg::Text(t))) => { + let v: serde_json::Value = serde_json::from_str(t.as_str()).expect("frame is JSON"); + assert_no_rooms_frame(&v); + out.push(v); + } + Some(Ok(_)) => {} + _ => break, + } + } + out +} + +/// Strong invariant: the core path must NEVER emit a frame whose `method` +/// starts with `"rooms/"`. This is the load-bearing assertion separating +/// the provider-neutral core from any AMUX layer. +fn assert_no_rooms_frame(frame: &serde_json::Value) { + if let Some(method) = frame.get("method").and_then(|m| m.as_str()) { + assert!( + !method.starts_with("rooms/"), + "core must never emit a rooms/* frame; saw method={method:?} in {frame:?}", + ); + } +} + +async fn close(mut ws: WsStream) { + let _ = ws.send(ClientMsg::Close(None)).await; +} + +// ===== (a) session/attach roster + historyPolicy shaping ===== + +#[tokio::test] +async fn attach_history_policy_full_returns_history_and_roster() { + let (addr, _) = spawn_server_with_mock().await; + let mut ws = connect(addr, "mux=full533&peer_id=A&peer_name=Alice").await; + + let _ = ws_request(&mut ws, r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#).await; + let _ = ws_request( + &mut ws, + r#"{"jsonrpc":"2.0","id":2,"method":"session/new"}"#, + ) + .await; + // Seed replay history with agent broadcast frames. + let _ = ws_request( + &mut ws, + r#"{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess-mock","prompt":[{"type":"text","text":"seed"}]}}"#, + ) + .await; + + let attach = ws_request( + &mut ws, + r#"{"jsonrpc":"2.0","id":4,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"full","clientId":"client-A"}}"#, + ) + .await; + + let result = &attach["result"]; + assert_eq!(result["sessionId"], serde_json::json!("sess-mock")); + assert_eq!(result["clientId"], serde_json::json!("client-A")); + assert_eq!(result["historyPolicy"], serde_json::json!("full")); + + // Top-level connectedClients roster includes this peer. + let roster = result["connectedClients"].as_array().expect("roster array"); + assert!( + roster + .iter() + .any(|c| c["clientId"] == serde_json::json!("A") + && c["name"] == serde_json::json!("Alice")), + "attach result must expose a top-level connectedClients roster: {attach:?}", + ); + + // Full history includes the replayed session/update broadcast frames. + let history = result["history"].as_array().expect("full history array"); + assert!( + history + .iter() + .any(|entry| entry["method"] == serde_json::json!("session/update")), + "full history should include replayed broadcast frames: {attach:?}", + ); + + close(ws).await; +} + +#[tokio::test] +async fn attach_history_policy_none_omits_history_but_keeps_roster() { + let (addr, _) = spawn_server_with_mock().await; + let mut ws = connect(addr, "mux=none533&peer_id=A&peer_name=Alice").await; + + let _ = ws_request(&mut ws, r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#).await; + let _ = ws_request( + &mut ws, + r#"{"jsonrpc":"2.0","id":2,"method":"session/new"}"#, + ) + .await; + let _ = ws_request( + &mut ws, + r#"{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess-mock","prompt":[{"type":"text","text":"seed"}]}}"#, + ) + .await; + + let attach = ws_request( + &mut ws, + r#"{"jsonrpc":"2.0","id":4,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"none","clientId":"client-A"}}"#, + ) + .await; + + let result = &attach["result"]; + assert_eq!(result["historyPolicy"], serde_json::json!("none")); + assert!( + result.get("history").is_none(), + "historyPolicy none must omit history: {attach:?}", + ); + let roster = result["connectedClients"].as_array().expect("roster array"); + assert!( + roster + .iter() + .any(|c| c["clientId"] == serde_json::json!("A")), + "roster should still be present for historyPolicy none: {attach:?}", + ); + + close(ws).await; +} + +#[tokio::test] +async fn attach_history_policy_pending_only_returns_open_permission() { + let (addr, _) = spawn_server_with_mock_env(&[ + ("MOCK_ACP_EMIT_PERMISSION", "1"), + ("MOCK_ACP_PROMPT_DELAY_MS", "2000"), + ]) + .await; + let mut ws_a = connect(addr, "mux=pending533&peer_id=A&peer_name=Alice").await; + + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, + ) + .await; + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":2,"method":"session/new"}"#, + ) + .await; + // Fire a prompt that triggers an agent-initiated permission request and + // then stalls (2s delay) so the permission stays open. + ws_a.send(ClientMsg::Text( + r#"{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess-mock","prompt":[{"type":"text","text":"needs approval"}]}}"#.into(), + )) + .await + .unwrap(); + let _permission = ws_next_method(&mut ws_a, "session/request_permission").await; + + // A second client attaches with pending_only and should see exactly the + // open permission in history. + let mut ws_b = connect(addr, "mux=pending533&peer_id=B&peer_name=Bob").await; + let _ = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":10,"method":"initialize"}"#, + ) + .await; + let attach = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":11,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"pending_only"}}"#, + ) + .await; + + let result = &attach["result"]; + assert_eq!(result["historyPolicy"], serde_json::json!("pending_only")); + let history = result["history"] + .as_array() + .expect("pending_only history array"); + assert_eq!(history.len(), 1, "pending_only history: {attach:?}"); + assert_eq!( + history[0]["method"], + serde_json::json!("session/request_permission"), + ); + assert_eq!( + history[0]["params"]["toolCall"]["status"], + serde_json::json!("pending"), + ); + + close(ws_a).await; + close(ws_b).await; +} + +// ===== (b) session/detach standard result shape ===== + +#[tokio::test] +async fn detach_returns_standard_result_shape() { + let (addr, _) = spawn_server_with_mock().await; + let mut ws_a = connect(addr, "mux=detach533&peer_id=A&peer_name=Alice").await; + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, + ) + .await; + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":2,"method":"session/new"}"#, + ) + .await; + + let mut ws_b = connect(addr, "mux=detach533&peer_id=B&peer_name=Bob").await; + let _ = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":10,"method":"initialize"}"#, + ) + .await; + let _ = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":11,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"none"}}"#, + ) + .await; + + let detached = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":12,"method":"session/detach","params":{"sessionId":"sess-mock"}}"#, + ) + .await; + let result = &detached["result"]; + assert_eq!(result["status"], serde_json::json!("detached")); + assert_eq!(result["sessionId"], serde_json::json!("sess-mock")); + + close(ws_a).await; + close(ws_b).await; +} + +// ===== (c) first-writer-wins permission resolution ===== + +#[tokio::test] +async fn permission_resolution_is_first_writer_wins() { + let (addr, _) = spawn_server_with_mock_env(&[ + ("MOCK_ACP_EMIT_PERMISSION", "1"), + ("MOCK_ACP_PROMPT_DELAY_MS", "2000"), + ]) + .await; + let mut ws_a = connect(addr, "mux=fww533&peer_id=A&peer_name=Alice").await; + let mut ws_b = connect(addr, "mux=fww533&peer_id=B&peer_name=Bob").await; + + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, + ) + .await; + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":2,"method":"session/new"}"#, + ) + .await; + + // Prompt triggers an agent-initiated permission, fanned out to both A and B. + ws_a.send(ClientMsg::Text( + r#"{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess-mock","prompt":[{"type":"text","text":"needs approval"}]}}"#.into(), + )) + .await + .unwrap(); + + let perm_a = ws_next_method(&mut ws_a, "session/request_permission").await; + let perm_b = ws_next_method(&mut ws_b, "session/request_permission").await; + let permission_id = perm_a["id"].clone(); + assert_eq!( + perm_a["id"], perm_b["id"], + "both clients see the same request id" + ); + + // A replies first; this reply should be forwarded to the agent. + let reply = serde_json::json!({ + "jsonrpc": "2.0", + "id": permission_id, + "result": { "outcome": { "outcome": "selected", "optionId": "allow_once" } }, + }); + ws_a.send(ClientMsg::Text(reply.to_string().into())) + .await + .unwrap(); + + // B replies later; this duplicate must be dropped (first-writer-wins). + let dup = serde_json::json!({ + "jsonrpc": "2.0", + "id": permission_id, + "result": { "outcome": { "outcome": "selected", "optionId": "deny" } }, + }); + // Give the first reply time to be consumed by the actor. + tokio::time::sleep(Duration::from_millis(100)).await; + ws_b.send(ClientMsg::Text(dup.to_string().into())) + .await + .unwrap(); + + // The prompt eventually settles with end_turn. The first-writer reply + // (A) is what gets forwarded to the agent; B's later duplicate is + // dropped by the core. We confirm the turn completes cleanly and that + // no rooms/* leaked (asserted on every frame drained). + // + // Drain A until the prompt response (id 3) arrives. + let deadline = std::time::Instant::now() + Duration::from_secs(4); + let mut saw_prompt_result = false; + while std::time::Instant::now() < deadline { + let Ok(Some(Ok(ClientMsg::Text(t)))) = + timeout(Duration::from_millis(150), ws_a.next()).await + else { + continue; + }; + let v: serde_json::Value = serde_json::from_str(t.as_str()).expect("frame is JSON"); + assert_no_rooms_frame(&v); + if v.get("id") == Some(&serde_json::json!(3)) && v.get("result").is_some() { + assert_eq!(v["result"]["stopReason"], serde_json::json!("end_turn")); + saw_prompt_result = true; + break; + } + } + assert!( + saw_prompt_result, + "expected A to receive its prompt response after first-writer permission resolution", + ); + + // Drain any residual frames on B; the no-rooms invariant is checked there too. + let _ = drain_for(&mut ws_b, Duration::from_millis(100)).await; + + close(ws_a).await; + close(ws_b).await; +} + +// ===== (d) INVARIANT: no rooms/* frames anywhere on the core path ===== + +#[tokio::test] +async fn core_never_emits_rooms_frames() { + let (addr, _) = spawn_server_with_mock_env(&[("MOCK_ACP_EMIT_PERMISSION", "1")]).await; + let mut ws_a = connect(addr, "mux=invariant533&peer_id=A&peer_name=Alice").await; + let mut ws_b = connect(addr, "mux=invariant533&peer_id=B&peer_name=Bob").await; + + // Full handshake + a prompt (with permission) + attach/detach lifecycle + // exercises every code path that produces outbound frames. + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, + ) + .await; + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":2,"method":"session/new"}"#, + ) + .await; + let _ = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":10,"method":"initialize"}"#, + ) + .await; + let _ = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":11,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"full"}}"#, + ) + .await; + + ws_a.send(ClientMsg::Text( + r#"{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess-mock","prompt":[{"type":"text","text":"hello"}]}}"#.into(), + )) + .await + .unwrap(); + + // Resolve the permission so the turn completes. + let perm = ws_next_method(&mut ws_b, "session/request_permission").await; + let reply = serde_json::json!({ + "jsonrpc": "2.0", + "id": perm["id"], + "result": { "outcome": { "outcome": "selected", "optionId": "allow_once" } }, + }); + ws_b.send(ClientMsg::Text(reply.to_string().into())) + .await + .unwrap(); + + // Drain a generous window of frames from both clients. assert_no_rooms_frame + // (invoked inside drain_for) fails the test if any rooms/* method appears. + let a_frames = drain_for(&mut ws_a, Duration::from_millis(400)).await; + let b_frames = drain_for(&mut ws_b, Duration::from_millis(400)).await; + + // Sanity: the core actually delivered real ACP traffic (not just silence). + let saw_real_traffic = a_frames + .iter() + .chain(b_frames.iter()) + .any(|v| v.get("method") == Some(&serde_json::json!("session/update"))); + assert!( + saw_real_traffic, + "expected real session/update traffic to validate the invariant against live frames", + ); + + // Detach exercises the lifecycle path; its result must also be rooms-free. + let detached = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":12,"method":"session/detach","params":{"sessionId":"sess-mock"}}"#, + ) + .await; + assert_eq!(detached["result"]["status"], serde_json::json!("detached")); + let _ = drain_for(&mut ws_a, Duration::from_millis(150)).await; + + close(ws_a).await; + close(ws_b).await; +} diff --git a/crates/rooms/Cargo.toml b/crates/rooms/Cargo.toml new file mode 100644 index 0000000..aefe7ce --- /dev/null +++ b/crates/rooms/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "rooms" +version = "0.1.3" +edition = "2024" + +# `src/bin/mock_acp.rs` is auto-detected for the integration test fixture. +# The library exists so integration tests under `tests/` can use the +# server/registry/protocol modules without spawning the binary. +[lib] +name = "rooms" +path = "src/lib.rs" + +[[bin]] +name = "rooms" +path = "src/main.rs" + +[dependencies] +acp-mux = { path = "../acp-mux" } +anyhow = "1.0.102" +axum = { version = "0.8.9", features = ["ws", "macros"] } +bytes = "1.11.1" +clap = { version = "4.6.1", features = ["derive"] } +futures = "0.3.32" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["full"] } +tokio-tungstenite = "0.29.0" +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] } +url = "2.5.8" diff --git a/crates/rooms/README.md b/crates/rooms/README.md new file mode 100644 index 0000000..8a1cf92 --- /dev/null +++ b/crates/rooms/README.md @@ -0,0 +1,186 @@ +# rooms + +`rooms` is the Rooms collaboration layer for `acp-mux`. + +It depends on the core `acp-mux` crate and supplies a `RoomsExtension`. The core +still owns subprocess management, JSON-RPC id routing, fanout, replay storage, +permission fan-in, and baseline `session/attach`. Rooms owns the multiplayer +protocol that lives under `rooms/*`. + +## What It Does + +For each `?room=`, `rooms` runs one core mux with Rooms hooks installed. +Subscribers in the same room share one upstream ACP agent subprocess and also +receive Rooms collaboration events. + +Rooms adds: + +- `?room=` WebSocket naming, with deprecated `?session=` alias; +- peer presence frames; +- initial `rooms/session_context`; +- turn lifecycle frames; +- active-turn busy events; +- active-turn steering; +- queued prompts; +- active-turn cancellation; +- permission request opened/resolved projection frames; +- pending permission reissue after attach; +- room segment tracking around `session/load` and observed ACP `sessionId` + changes; +- Rooms metadata in `session/attach`; +- segment-scoped `full` history; +- `full_lineage` history across room segments; +- newest-turn-first attach ordering; +- streamed attach history with `rooms/replay_started` and + `rooms/replay_complete`; +- optional append-only JSONL replay persistence through `--replay-store`; +- enriched `/debug/sessions` room snapshots. + +Rooms does not replace ACP. Agent-owned frames stay agent-owned. If a frame uses +`method: "session/update"`, it came from the upstream agent. Rooms-owned room +facts use `rooms/*`. + +## Run + +```sh +rooms \ + --agent-cmd 'cat' \ + --host 127.0.0.1 \ + --port 8765 +``` + +Connect: + +```text +ws://127.0.0.1:8765/acp?room=work&peer_id=desktop&peer_name=Desktop +``` + +Attach-aware clients usually suppress legacy WebSocket replay and request +history explicitly: + +```text +ws://127.0.0.1:8765/acp?room=work&peer_id=desktop&replay=skip +``` + +Then send: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "session/attach", + "params": { + "historyPolicy": "full", + "_meta": { + "rooms": { + "replayOrder": "chronological", + "historyDelivery": "response" + } + } + } +} +``` + +## CLI + +| Flag | Default | Meaning | +|---|---:|---| +| `--host` | `127.0.0.1` | HTTP/WebSocket bind address. | +| `--port` | `8765` | HTTP/WebSocket port. | +| `--agent-cmd` | none | Command and whitespace-split args used to spawn each room's ACP agent. | +| `--session-ttl-seconds` | `60` | Seconds to retain an empty room before shutting down its agent. | +| `--replay-turns` | `unbounded` | In-memory replay policy. | +| `--replay-store` | none | Optional append-only JSONL replay directory. | +| `--meta-propagate` | `false` | Add mux trace fields under `params._meta.rooms` on subscriber-to-agent requests. | +| `--unsafe-debug-client-tool-broadcast` | `false` | Raw-broadcast delegated `fs/*` and `terminal/*` requests. | +| `--emit-segment-frames` | `true` | Emit segment lifecycle frames. | +| `--log-level` | `info` | Logging level. `RUST_LOG` takes precedence. | + +`--agent-cmd` is split on whitespace and is not run through a shell. + +## Main `rooms/*` Frames + +Presence and context: + +- `rooms/session_context` +- `rooms/peer_joined` +- `rooms/peer_left` + +Turns: + +- `rooms/turn_started` +- `rooms/turn_complete` +- `rooms/turn_cancelled` +- `rooms/session_busy` +- `rooms/control_submitted` + +Queue: + +- `rooms/queue_item_added` +- `rooms/queue_item_submitted` +- `rooms/queue_item_completed` +- `rooms/queue_item_removed` +- `rooms/queue_item_orphaned` + +Agent request projection: + +- `rooms/agent_request_opened` +- `rooms/agent_request_resolved` + +Replay and segments: + +- `rooms/replay_started` +- `rooms/replay_complete` +- `rooms/segment_started` +- `rooms/segment_ended` + +Control requests from subscribers: + +- `rooms/steer_active_turn` +- `rooms/queue_prompt` +- `rooms/unqueue_prompt` +- `rooms/cancel_active_turn` + +See [../../docs/design/rooms-namespace.md](../../docs/design/rooms-namespace.md) +for wire shapes. + +## Replay Store + +With `--replay-store `, Rooms persists broadcast-tier replay frames in one +JSONL file per room: + +```text +/.jsonl +``` + +This persists visible mux replay history. It does not persist the upstream +agent's private conversation store or in-flight permissions. + +On restart the broadcast log is rehydrated for `historyPolicy: full_lineage` +recovery, but segment lineage and current-segment (`full`) scoping are not +reconstructed yet — see "cross-restart segment fidelity" in +[../../docs/design/rooms.md](../../docs/design/rooms.md). + +`--replay-turns 0` disables replay and prevents replay-store writes. + +## Debug Endpoint + +`GET /debug/sessions` returns `rooms` rather than the core `muxes` field and +adds Rooms fields such as: + +- `roomId`; +- `activeTurnMuxId`; +- `drivingSubscriber`; +- `activeRoomsTurnId`; +- `replayGeneration`; +- `lastReplayReset`; +- `segments`; +- `activeSegmentId`; +- `replayLogUpdateFramesByAcpSessionId`. + +## More Docs + +- [Core mux README](../acp-mux/README.md) +- [`rooms/*` namespace](../../docs/design/rooms-namespace.md) +- [Rooms and segments](../../docs/design/rooms.md) +- [Client contract examples](../../docs/examples/client-contract) diff --git a/crates/rooms/src/agent/mod.rs b/crates/rooms/src/agent/mod.rs new file mode 100644 index 0000000..faae074 --- /dev/null +++ b/crates/rooms/src/agent/mod.rs @@ -0,0 +1 @@ +pub use acp_mux::agent::process; diff --git a/src/bin/mock_acp.rs b/crates/rooms/src/bin/mock_acp.rs similarity index 90% rename from src/bin/mock_acp.rs rename to crates/rooms/src/bin/mock_acp.rs index 190fde1..a96a764 100644 --- a/src/bin/mock_acp.rs +++ b/crates/rooms/src/bin/mock_acp.rs @@ -49,12 +49,12 @@ //! in the `initialize` response and respond to `session/list` with //! a small canned set of sessions (the current `sess-mock` plus two //! historical entries). Used to test session/list end-to-end -//! passthrough through amux. +//! passthrough through rooms. //! - `MOCK_ACP_ECHO_INITIALIZE_PARAMS=1` — include the received //! `initialize.params` in the initialize result under //! `_seenInitializeParams`. Used by proxy sanitization tests. //! - `MOCK_ACP_SESSION_LIST_META=1` — add agent-owned `_meta` and -//! `_meta.amux` fields to the current session/list entry so tests can +//! `_meta.rooms` fields to the current session/list entry so tests can //! verify mux decoration merges instead of replacing upstream metadata. //! - `MOCK_ACP_FAIL_LOAD=1` — return an error response to //! `session/load` instead of succeeding. Used to test that failed @@ -63,6 +63,12 @@ //! two `session/update` history chunks for the requested session before //! returning the load response. Used to verify mux replay generation //! boundaries retain load-time replay frames. +//! - `MOCK_ACP_ROTATE_SESSION_ID=` — on `session/prompt`, after the normal +//! updates and before the response, emit one `session/update` under `` +//! (a different sessionId). The mux observes the sessionId change and rotates +//! the segment mid-turn, so the turn's `rooms/turn_started` lands in the +//! prior segment and its `rooms/turn_complete` in the new one. Used to test +//! notification-driven `historyPolicy: full` segment scoping and carry. //! //! Per-line behavior is logged to stderr at info level so tests can grep //! the output if needed. The process exits when stdin closes. @@ -118,6 +124,14 @@ fn main() { let emit_load_history = env::var("MOCK_ACP_EMIT_LOAD_HISTORY") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); + // Notification-driven segment rotation: on `session/prompt`, after the + // normal updates and before the response, emit one `session/update` under + // this (different) sessionId. The mux observes the sessionId change and + // rotates the segment mid-turn, so the turn's `rooms/turn_started` lands in + // the prior segment and its `rooms/turn_complete` in the new one. + let rotate_session_id = env::var("MOCK_ACP_ROTATE_SESSION_ID") + .ok() + .filter(|v| !v.trim().is_empty()); let mut initialize_count: u32 = 0; let mut session_new_count: u32 = 0; @@ -261,8 +275,8 @@ fn main() { "updatedAt": "2026-05-22T12:00:00Z", "_meta": { "agentKey": "preserved", - "amux": { - "agentAmuxKey": "preserved", + "rooms": { + "agentRoomsKey": "preserved", "proxySessionId": "agent-owned-placeholder" } } @@ -439,6 +453,25 @@ fn main() { writeln!(stdout, "{upd}").ok(); } + // Optional mid-turn notification-driven segment rotation: emit + // a session/update under a different sessionId so the mux + // rotates the segment while the turn is still in flight. + if let Some(rotated) = rotate_session_id.as_deref() { + let upd = json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": rotated, + "update": { + "kind": "agent_message_chunk", + "content": { "type": "text", "text": "rotated-segment-chunk" }, + }, + }, + }); + writeln!(stdout, "{upd}").ok(); + stdout.flush().ok(); + } + if prompt_delay_ms > 0 { stdout.flush().ok(); thread::sleep(Duration::from_millis(prompt_delay_ms)); @@ -472,11 +505,11 @@ fn mock_client_tool_params(method: &str, session_id: &Value) -> Value { match method { "fs/read_text_file" => json!({ "sessionId": session_id, - "path": "/tmp/amux-client-tool-read.txt", + "path": "/tmp/rooms-client-tool-read.txt", }), "fs/write_text_file" => json!({ "sessionId": session_id, - "path": "/tmp/amux-client-tool-write.txt", + "path": "/tmp/rooms-client-tool-write.txt", "content": "hello from mock_acp", }), "terminal/create" => json!({ diff --git a/src/cli.rs b/crates/rooms/src/cli.rs similarity index 67% rename from src/cli.rs rename to crates/rooms/src/cli.rs index 04b33d1..a816ba2 100644 --- a/src/cli.rs +++ b/crates/rooms/src/cli.rs @@ -1,11 +1,12 @@ use std::net::IpAddr; use std::path::PathBuf; +pub use acp_mux::cli::{ClientToolMode, ClientToolPolicy, ReplayTurns, split_agent_cmd}; use clap::{Parser, ValueEnum}; #[derive(Debug, Parser)] #[command( - name = "amux", + name = "rooms", version, about = "Multi-subscriber ACP session multiplexer" )] @@ -36,7 +37,7 @@ pub struct Cli { pub replay_turns: ReplayTurns, /// Opt-in: persist the broadcast replay log to the given directory so - /// late joiners can recover history across `amux` restarts. One JSONL + /// late joiners can recover history across `rooms` restarts. One JSONL /// file per room (`/.jsonl`). Has no effect when /// `--replay-turns 0`. The store is append-only and unbounded; delete /// files (or the directory) to clear history. @@ -44,7 +45,7 @@ pub struct Cli { pub replay_store: Option, /// Opt into injecting mux-owned trace metadata into subscriber → agent - /// requests under params._meta.amux. + /// requests under params._meta.rooms. #[arg(long, default_value_t = false)] pub meta_propagate: bool, @@ -53,7 +54,7 @@ pub struct Cli { #[arg(long, default_value_t = false)] pub unsafe_debug_client_tool_broadcast: bool, - /// Emit `amux/segment_started` and `amux/segment_ended` lifecycle frames + /// Emit `rooms/segment_started` and `rooms/segment_ended` lifecycle frames /// on segment rotation (`session/load` or observed ACP `sessionId` changes). Default on; /// pass `--emit-segment-frames=false` to keep wire output byte-equivalent /// with v0.1.x for clients that haven't picked up the new frame methods @@ -93,50 +94,6 @@ impl LogLevel { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ClientToolMode { - Block, - UnsafeDebug, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ClientToolPolicy { - pub fs: ClientToolMode, - pub terminal: ClientToolMode, -} - -impl ClientToolPolicy { - pub fn block_by_default() -> Self { - Self { - fs: ClientToolMode::Block, - terminal: ClientToolMode::Block, - } - } - - pub fn unsafe_debug_broadcast() -> Self { - Self { - fs: ClientToolMode::UnsafeDebug, - terminal: ClientToolMode::UnsafeDebug, - } - } - - pub fn mode_for_method(&self, method: &str) -> Option { - if method.starts_with("fs/") { - Some(self.fs) - } else if method.starts_with("terminal/") { - Some(self.terminal) - } else { - None - } - } -} - -impl Default for ClientToolPolicy { - fn default() -> Self { - Self::block_by_default() - } -} - impl Cli { pub fn client_tool_policy(&self) -> ClientToolPolicy { if self.unsafe_debug_client_tool_broadcast { @@ -147,38 +104,6 @@ impl Cli { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReplayTurns { - Disabled, - Bounded(u32), - Unbounded, -} - -impl std::str::FromStr for ReplayTurns { - type Err = String; - fn from_str(s: &str) -> Result { - if s.eq_ignore_ascii_case("unbounded") { - return Ok(ReplayTurns::Unbounded); - } - let n: u32 = s - .parse() - .map_err(|_| format!("expected \"unbounded\" or a non-negative integer, got {s:?}"))?; - Ok(if n == 0 { - ReplayTurns::Disabled - } else { - ReplayTurns::Bounded(n) - }) - } -} - -/// Split `--agent-cmd` into (program, args). Whitespace-only splitting; no -/// shell quote handling. Returns `None` if the string is empty after trim. -pub fn split_agent_cmd(raw: &str) -> Option<(String, Vec)> { - let mut it = raw.split_whitespace().map(str::to_string); - let prog = it.next()?; - Ok::<_, ()>((prog, it.collect())).ok() -} - #[cfg(test)] mod tests { use super::*; @@ -227,34 +152,34 @@ mod tests { #[test] fn replay_store_defaults_off() { - let cli = Cli::try_parse_from(["amux"]).unwrap(); + let cli = Cli::try_parse_from(["rooms"]).unwrap(); assert!(cli.replay_store.is_none()); } #[test] fn replay_store_accepts_path() { - let cli = Cli::try_parse_from(["amux", "--replay-store", "/tmp/amux-replay"]).unwrap(); + let cli = Cli::try_parse_from(["rooms", "--replay-store", "/tmp/rooms-replay"]).unwrap(); assert_eq!( cli.replay_store.as_deref(), - Some(std::path::Path::new("/tmp/amux-replay")) + Some(std::path::Path::new("/tmp/rooms-replay")) ); } #[test] fn meta_propagate_defaults_off() { - let cli = Cli::try_parse_from(["amux"]).unwrap(); + let cli = Cli::try_parse_from(["rooms"]).unwrap(); assert!(!cli.meta_propagate); } #[test] fn meta_propagate_flag_enables_trace_injection() { - let cli = Cli::try_parse_from(["amux", "--meta-propagate"]).unwrap(); + let cli = Cli::try_parse_from(["rooms", "--meta-propagate"]).unwrap(); assert!(cli.meta_propagate); } #[test] fn client_tool_policy_blocks_fs_and_terminal_by_default() { - let cli = Cli::try_parse_from(["amux"]).unwrap(); + let cli = Cli::try_parse_from(["rooms"]).unwrap(); let policy = cli.client_tool_policy(); assert_eq!( policy.mode_for_method("fs/read_text_file"), @@ -278,39 +203,39 @@ mod tests { #[test] fn emit_segment_frames_defaults_on() { - let cli = Cli::try_parse_from(["amux"]).unwrap(); + let cli = Cli::try_parse_from(["rooms"]).unwrap(); assert!(cli.emit_segment_frames); } #[test] fn emit_segment_frames_can_be_disabled_with_explicit_false() { - let cli = Cli::try_parse_from(["amux", "--emit-segment-frames=false"]).unwrap(); + let cli = Cli::try_parse_from(["rooms", "--emit-segment-frames=false"]).unwrap(); assert!(!cli.emit_segment_frames); - let cli = Cli::try_parse_from(["amux", "--emit-segment-frames", "false"]).unwrap(); + let cli = Cli::try_parse_from(["rooms", "--emit-segment-frames", "false"]).unwrap(); assert!(!cli.emit_segment_frames); } #[test] fn emit_segment_frames_accepts_explicit_true() { - let cli = Cli::try_parse_from(["amux", "--emit-segment-frames=true"]).unwrap(); + let cli = Cli::try_parse_from(["rooms", "--emit-segment-frames=true"]).unwrap(); assert!(cli.emit_segment_frames); } #[test] fn emit_segment_frames_bare_treated_as_true() { - let cli = Cli::try_parse_from(["amux", "--emit-segment-frames"]).unwrap(); + let cli = Cli::try_parse_from(["rooms", "--emit-segment-frames"]).unwrap(); assert!(cli.emit_segment_frames); } #[test] fn emit_segment_frames_rejects_non_bool() { - let result = Cli::try_parse_from(["amux", "--emit-segment-frames=maybe"]); + let result = Cli::try_parse_from(["rooms", "--emit-segment-frames=maybe"]); assert!(result.is_err(), "non-bool values must be rejected"); } #[test] fn unsafe_debug_flag_enables_fs_and_terminal_broadcast() { - let cli = Cli::try_parse_from(["amux", "--unsafe-debug-client-tool-broadcast"]).unwrap(); + let cli = Cli::try_parse_from(["rooms", "--unsafe-debug-client-tool-broadcast"]).unwrap(); let policy = cli.client_tool_policy(); assert_eq!( policy.mode_for_method("fs/write_text_file"), diff --git a/crates/rooms/src/extension/attach_views.rs b/crates/rooms/src/extension/attach_views.rs new file mode 100644 index 0000000..6201d10 --- /dev/null +++ b/crates/rooms/src/extension/attach_views.rs @@ -0,0 +1,334 @@ +//! `session/attach` enrichment hooks for `RoomsExtension`. + +use super::*; + +impl RoomsExtension { + /// Whether a replay entry belongs in the `historyPolicy: full` view: the + /// active segment, plus pre-segment bootstrap frames (`SegmentId(0)`), plus + /// `rooms/turn_{started,complete,cancelled}` lifecycle bookends carried from + /// a prior segment when their `roomsTurnId` brackets the active view (so a + /// mid-rotation turn is never left with an unmatched complete). Prior-segment + /// agent chunks are excluded — those belong to `full_lineage`. + /// + /// Driven purely by per-frame segment tags (`ext_tag`), never by + /// `replay_generation`, so it is correct after both load-driven and + /// notification-driven segment rotations. + pub(super) fn should_include_replay_entry(&self, ctx: &MuxCtx, entry: ReplayView<'_>) -> bool { + let active = self.active_segment_id.map(|id| id.0); + if entry.ext_tag == PRE_SEGMENT_TAG || Some(entry.ext_tag) == active { + return true; + } + // A prior-segment frame: keep it only if it is a turn-lifecycle bookend + // whose turn brackets the active view. + turn_lifecycle_turn_id(entry.frame) + .map(|turn_id| self.cross_segment_turn_carry(ctx).contains(&turn_id)) + .unwrap_or(false) + } + + /// Turn ids whose `rooms/turn_*` bookends should be carried into the `full` + /// view from prior segments: any turn id seen in a bootstrap or + /// active-segment lifecycle frame, plus the currently-active turn. + fn cross_segment_turn_carry(&self, ctx: &MuxCtx) -> std::collections::HashSet { + let active = self.active_segment_id.map(|id| id.0); + let mut carry = std::collections::HashSet::new(); + for entry in ctx.replay_entries() { + if (entry.ext_tag == PRE_SEGMENT_TAG || Some(entry.ext_tag) == active) + && let Some(turn_id) = turn_lifecycle_turn_id(entry.frame) + { + carry.insert(turn_id); + } + } + if let Some(turn_id) = self.active_rooms_turn_id { + carry.insert(turn_id.formatted()); + } + carry + } + + pub(super) fn replay_history(&self, ctx: &MuxCtx, full_lineage: bool) -> Vec { + ctx.replay_entries() + .filter(|entry| full_lineage || self.should_include_replay_entry(ctx, entry.clone())) + .filter_map(history_entry_from_replay) + .collect() + } + + pub(super) fn replay_retention_counts(&self, ctx: &MuxCtx) -> (usize, usize) { + let mut dropped = 0; + let mut retained = 0; + for entry in ctx.replay_entries() { + if self.should_include_replay_entry(ctx, entry) { + retained += 1; + } else { + dropped += 1; + } + } + (dropped, retained) + } + + pub(super) fn replay_update_counts_by_session(&self, ctx: &MuxCtx) -> Map { + let mut counts: HashMap = HashMap::new(); + for entry in ctx.replay_entries() { + if !self.should_include_replay_entry(ctx, entry.clone()) { + continue; + } + let Ok(value) = serde_json::from_slice::(entry.frame) else { + continue; + }; + if value.get("method").and_then(Value::as_str) != Some("session/update") { + continue; + } + let Some(session_id) = value.pointer("/params/sessionId").and_then(Value::as_str) + else { + continue; + }; + *counts.entry(session_id.to_string()).or_default() += 1; + } + counts + .into_iter() + .map(|(session_id, count)| (session_id, Value::Number(serde_json::Number::from(count)))) + .collect() + } + + pub(super) fn send_replay_phase(&self, ctx: &mut MuxCtx, phase: &WakeReplayPhase) { + let room_id = ctx.mux_id().to_string(); + ctx.send_to( + &phase.peer_id, + Bytes::from(rooms::replay_started( + &room_id, + &phase.phase, + &phase.replay_order, + phase.replay_generation, + phase.replay_boundary_seq, + phase.frames.len(), + )), + ); + for frame in &phase.frames { + ctx.send_to(&phase.peer_id, Bytes::from(frame.clone())); + } + ctx.send_to( + &phase.peer_id, + Bytes::from(rooms::replay_complete( + &room_id, + &phase.phase, + &phase.replay_order, + phase.replay_generation, + phase.replay_boundary_seq, + phase.frames.len(), + )), + ); + } +} + +/// Replay tag for frames recorded before any segment opened (the pre-segment +/// bootstrap). Matches the `SegmentId(0)` sentinel used by `set_replay_tag`. +const PRE_SEGMENT_TAG: u64 = 0; + +/// If `frame` is a `rooms/turn_started`, `rooms/turn_complete`, or +/// `rooms/turn_cancelled` notification, return its `roomsTurnId`. +fn turn_lifecycle_turn_id(frame: &[u8]) -> Option { + let value: Value = serde_json::from_slice(frame).ok()?; + let method = value.get("method").and_then(Value::as_str)?; + if !matches!( + method, + "rooms/turn_started" | "rooms/turn_complete" | "rooms/turn_cancelled" + ) { + return None; + } + value + .pointer("/params/roomsTurnId") + .and_then(Value::as_str) + .map(str::to_string) +} + +pub(super) fn attach_meta_str<'a>(params: &'a AttachParams, key: &str) -> Option<&'a str> { + params + .meta + .as_ref() + .and_then(|meta| meta.get("rooms")) + .and_then(|rooms| rooms.get(key)) + .and_then(Value::as_str) +} + +fn entry_to_frame(entry: acp_mux::attach::HistoryEntry) -> Value { + let mut frame = Map::new(); + frame.insert("jsonrpc".to_string(), Value::String("2.0".to_string())); + frame.insert("method".to_string(), Value::String(entry.method)); + if let Some(params) = entry.params { + frame.insert("params".to_string(), params); + } + Value::Object(frame) +} + +fn history_entry_from_replay(entry: ReplayView<'_>) -> Option { + let frame = inject_replay_metadata(entry.frame, entry.recorded_at, entry.seq); + let value: Value = serde_json::from_slice(&frame).ok()?; + let object = value.as_object()?; + let method = object.get("method")?.as_str()?.to_string(); + let params = object.get("params").cloned(); + Some(HistoryEntry { method, params }) +} + +pub(super) fn schedule_wake_payload( + ctx: &mut MuxCtx, + delay: std::time::Duration, + payload: WakePayload, +) { + if let Ok(bytes) = serde_json::to_vec(&payload) { + ctx.schedule_wake(delay, bytes); + } +} + +pub(super) fn replay_stream_phases( + peer_id: &str, + replay_order: &str, + replay_generation: u64, + replay_boundary_seq: u64, + history: Vec, +) -> Vec { + if replay_order != "newest_turn_first" { + return vec![WakeReplayPhase { + peer_id: peer_id.to_string(), + phase: "backfill".to_string(), + replay_order: replay_order.to_string(), + replay_generation, + replay_boundary_seq, + frames: history_entries_to_frame_strings(history), + }]; + } + + let (prefix, groups) = turn_groups(history); + let Some((latest, older)) = groups.split_first() else { + return vec![WakeReplayPhase { + peer_id: peer_id.to_string(), + phase: "latest_segment".to_string(), + replay_order: replay_order.to_string(), + replay_generation, + replay_boundary_seq, + frames: history_entries_to_frame_strings(prefix), + }]; + }; + + let mut latest_segment = prefix; + latest_segment.extend(latest.clone()); + let mut phases = vec![WakeReplayPhase { + peer_id: peer_id.to_string(), + phase: "latest_segment".to_string(), + replay_order: replay_order.to_string(), + replay_generation, + replay_boundary_seq, + frames: history_entries_to_frame_strings(latest_segment), + }]; + let backfill_entries = older + .iter() + .flat_map(|group| group.iter().cloned()) + .collect::>(); + if !backfill_entries.is_empty() { + phases.push(WakeReplayPhase { + peer_id: peer_id.to_string(), + phase: "backfill".to_string(), + replay_order: replay_order.to_string(), + replay_generation, + replay_boundary_seq, + frames: history_entries_to_frame_strings(backfill_entries), + }); + } + phases +} + +fn history_entries_to_frame_strings(history: Vec) -> Vec { + history + .into_iter() + .filter_map(|entry| serde_json::to_string(&entry_to_frame(entry)).ok()) + .collect() +} + +fn turn_groups(history: Vec) -> (Vec, Vec>) { + let mut groups: Vec> = Vec::new(); + let mut prefix: Vec = Vec::new(); + let mut current: Option> = None; + + for entry in history { + if entry.method == "rooms/turn_started" { + if let Some(group) = current.take() { + groups.push(group); + } + current = Some(vec![entry]); + } else if let Some(group) = current.as_mut() { + let closes = + entry.method == "rooms/turn_complete" || entry.method == "rooms/turn_cancelled"; + group.push(entry); + if closes && let Some(group) = current.take() { + groups.push(group); + } + } else { + prefix.push(entry); + } + } + if let Some(group) = current { + groups.push(group); + } + (prefix, groups) +} + +pub(super) fn newest_turn_first_history( + history: Vec, +) -> Vec { + let mut groups: Vec> = Vec::new(); + let mut prefix: Vec = Vec::new(); + let mut current: Option> = None; + + for entry in history { + if entry.method == "rooms/turn_started" { + if let Some(group) = current.take() { + groups.push(group); + } + current = Some(vec![entry]); + } else if let Some(group) = current.as_mut() { + let closes = + entry.method == "rooms/turn_complete" || entry.method == "rooms/turn_cancelled"; + group.push(entry); + if closes && let Some(group) = current.take() { + groups.push(group); + } + } else { + prefix.push(entry); + } + } + if let Some(group) = current { + groups.push(group); + } + + let mut out = prefix; + for group in groups.into_iter().rev() { + out.extend(group); + } + out +} + +pub(super) fn inject_replay_metadata(frame: &Bytes, recorded_at: &str, replay_seq: u64) -> Bytes { + let Ok(mut value) = serde_json::from_slice::(frame) else { + return frame.clone(); + }; + let Value::Object(root) = &mut value else { + return frame.clone(); + }; + let Some(params) = object_field(root, "params") else { + return frame.clone(); + }; + let Some(meta) = object_field(params, "_meta") else { + return frame.clone(); + }; + let Some(rooms) = object_field(meta, "rooms") else { + return frame.clone(); + }; + rooms.insert( + "recordedAt".to_string(), + Value::String(recorded_at.to_string()), + ); + rooms.insert( + "replaySeq".to_string(), + Value::Number(serde_json::Number::from(replay_seq)), + ); + serde_json::to_vec(&value) + .map(Bytes::from) + .unwrap_or_else(|_| frame.clone()) +} diff --git a/crates/rooms/src/extension/mod.rs b/crates/rooms/src/extension/mod.rs new file mode 100644 index 0000000..f6c34c8 --- /dev/null +++ b/crates/rooms/src/extension/mod.rs @@ -0,0 +1,851 @@ +mod attach_views; +mod presence; +mod queue; +mod segments; +mod turns; + +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use acp_mux::attach::{AttachParams, AttachResult, HistoryEntry, HistoryPolicy}; +use acp_mux::extension::{Disposition, MuxCtx, MuxExtension, NotifyDisposition, ResolvedBy}; +use acp_mux::jsonrpc::{ + Id, IncomingNotification, IncomingRequest, IncomingResponse, JsonRpcError, JsonRpcVersion, +}; +use acp_mux::mux::ReplayView; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::protocol::rooms::{self, EndReason, RoomsTurnId, SegmentId}; + +use attach_views::{ + attach_meta_str, inject_replay_metadata, newest_turn_first_history, replay_stream_phases, + schedule_wake_payload, +}; +use queue::{ + RequestTrace, build_session_cancel, inject_request_trace_metadata, text_from_text_only_prompt, +}; + +#[derive(Debug, Clone)] +pub struct RoomsOptions { + pub meta_propagate: bool, + pub emit_segment_frames: bool, +} + +impl Default for RoomsOptions { + fn default() -> Self { + Self { + meta_propagate: false, + emit_segment_frames: true, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct RoomsRequestData { + pub(crate) rooms_turn_id: Option, + pub(crate) queue_item_id: Option, + pub(crate) decorate_session_list: bool, +} + +#[derive(Debug, Clone)] +pub(crate) enum QueuedPromptKind { + Prompt, + Queue, + HardSteer { supersedes_turn_id: RoomsTurnId }, +} + +#[derive(Debug, Clone)] +pub(crate) struct QueuedPrompt { + pub(crate) queue_item_id: Option, + pub(crate) peer_id: String, + pub(crate) session_id: String, + pub(crate) prompt_text: String, + pub(crate) kind: QueuedPromptKind, +} + +#[derive(Debug, Default)] +pub struct SessionListMetadataIndex { + inner: std::sync::RwLock>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionListRoomsMetadata { + pub room_id: String, + pub subscriber_count: usize, + pub driving_subscriber: Option, +} + +impl SessionListMetadataIndex { + pub fn new() -> Self { + Self::default() + } + + pub fn get(&self, acp_session_id: &str) -> Option { + self.inner + .read() + .expect("session list metadata index poisoned") + .get(acp_session_id) + .cloned() + } + + fn upsert(&self, acp_session_id: &str, metadata: SessionListRoomsMetadata) { + self.inner + .write() + .expect("session list metadata index poisoned") + .insert(acp_session_id.to_string(), metadata); + } + + fn remove_if_room(&self, acp_session_id: &str, room_id: &str) { + let mut index = self + .inner + .write() + .expect("session list metadata index poisoned"); + if index + .get(acp_session_id) + .is_some_and(|meta| meta.room_id == room_id) + { + index.remove(acp_session_id); + } + } +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplayResetSnapshot { + pub loaded_session_id: String, + pub replay_generation: u64, + pub dropped_frame_count: usize, + pub retained_frame_count: usize, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Segment { + pub id: SegmentId, + #[serde(skip_serializing_if = "Option::is_none")] + pub acp_session_id: Option, + pub opened_at: String, + pub opened_replay_seq: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub closed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub closed_replay_seq: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_reason: Option, +} + +impl Segment { + fn open(id: SegmentId, acp_session_id: Option, opened_replay_seq: u64) -> Self { + Self { + id, + acp_session_id, + opened_at: utc_rfc3339_now(), + opened_replay_seq, + closed_at: None, + closed_replay_seq: None, + end_reason: None, + } + } +} + +pub struct RoomsExtension { + pub(crate) options: RoomsOptions, + pub(crate) active_rooms_turn_id: Option, + pub(crate) active_turn_session_id: Option, + pub(crate) active_turn_prompt_text: Option, + pub(crate) next_rooms_turn_id: u64, + pub(crate) per_request: HashMap, + pub(crate) queued_prompts: VecDeque, + pub(crate) next_queue_item_id: u64, + pub(crate) active_segment_id: Option, + pub(crate) segments: Vec, + pub(crate) next_segment_id: u64, + pub(crate) replay_generation: u64, + pub(crate) last_replay_reset: Option, + pub(crate) driving_subscriber_peer_id: Option, + pub(crate) session_list_index: Arc, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(super) enum WakePayload { + ReplayStream { + phases: Vec, + }, + PendingPermissions { + peer_id: String, + frames: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct WakeReplayPhase { + pub(super) peer_id: String, + pub(super) phase: String, + pub(super) replay_order: String, + pub(super) replay_generation: u64, + pub(super) replay_boundary_seq: u64, + pub(super) frames: Vec, +} + +impl RoomsExtension { + pub fn new(options: RoomsOptions, session_list_index: Arc) -> Self { + Self { + options, + active_rooms_turn_id: None, + active_turn_session_id: None, + active_turn_prompt_text: None, + next_rooms_turn_id: 1, + per_request: HashMap::new(), + queued_prompts: VecDeque::new(), + next_queue_item_id: 1, + active_segment_id: None, + segments: Vec::new(), + next_segment_id: 1, + replay_generation: 0, + last_replay_reset: None, + driving_subscriber_peer_id: None, + session_list_index, + } + } +} + +pub(super) const MAX_MUX_QUEUE_PROMPTS: usize = 6; +const SESSION_BUSY_ERROR_CODE: i64 = -32001; +pub(super) const NO_ACTIVE_TURN_ERROR_CODE: i64 = -32002; +pub(super) const QUEUE_FULL_ERROR_CODE: i64 = -32003; +pub(super) const QUEUE_ITEM_NOT_FOUND_ERROR_CODE: i64 = -32004; +pub(super) const INVALID_PARAMS_ERROR_CODE: i64 = -32602; +pub(super) const SESSION_CANCEL_METHOD: &str = "session/cancel"; + +impl RoomsExtension { + pub(super) fn send_error_response( + &self, + ctx: &mut MuxCtx, + peer_id: &str, + id: Id, + code: i64, + message: &str, + ) { + let resp = IncomingResponse { + jsonrpc: JsonRpcVersion, + id, + result: None, + error: Some(JsonRpcError { + code, + message: message.to_string(), + data: None, + }), + }; + if let Ok(bytes) = serde_json::to_vec(&resp) { + ctx.send_to(peer_id, Bytes::from(bytes)); + } + } + + pub(super) fn send_result_response( + &self, + ctx: &mut MuxCtx, + peer_id: &str, + id: Id, + result: Value, + ) { + let resp = IncomingResponse { + jsonrpc: JsonRpcVersion, + id, + result: Some(result), + error: None, + }; + if let Ok(bytes) = serde_json::to_vec(&resp) { + ctx.send_to(peer_id, Bytes::from(bytes)); + } + } + + /// Cancel the active turn, if any: broadcast `rooms/turn_cancelled` and + /// send ACP-native `session/cancel` to the agent. Returns the cancelled + /// turn id, or `None` when there is no active turn. Shared by the request + /// (`on_subscriber_request`) and notification (`on_subscriber_notification`) + /// entry points so both documented shapes behave identically and neither + /// is ever forwarded to the agent. + fn cancel_active_turn( + &self, + ctx: &mut MuxCtx, + peer_id: &str, + reason: Option<&str>, + ) -> Option { + let rooms_turn_id = self.active_rooms_turn_id?; + let active_session_id = self.active_turn_session_id.clone()?; + let original_driver = ctx + .prompt_in_flight() + .and_then(|mux_id| ctx.pending_peer(mux_id).map(str::to_string)) + .unwrap_or_else(|| peer_id.to_string()); + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::turn_cancelled( + &room_id, + rooms_turn_id, + peer_id, + &original_driver, + reason, + )); + ctx.send_to_agent(build_session_cancel(&active_session_id)); + Some(rooms_turn_id) + } +} + +impl MuxExtension for RoomsExtension { + fn on_subscriber_request( + &mut self, + ctx: &mut MuxCtx, + peer_id: &str, + req: &mut IncomingRequest, + ) -> Disposition { + match req.method.as_str() { + rooms::METHOD_STEER_ACTIVE_TURN => self.handle_rooms_steer_request(ctx, peer_id, req), + rooms::METHOD_QUEUE_PROMPT => self.handle_rooms_queue_prompt_request(ctx, peer_id, req), + rooms::METHOD_UNQUEUE_PROMPT => { + self.handle_rooms_unqueue_prompt_request(ctx, peer_id, req) + } + rooms::METHOD_CANCEL_ACTIVE_TURN => { + let reason = req + .params + .as_ref() + .and_then(|v| v.get("reason")) + .and_then(Value::as_str) + .map(str::to_string); + match self.cancel_active_turn(ctx, peer_id, reason.as_deref()) { + Some(turn_id) => { + self.send_result_response( + ctx, + peer_id, + req.id.clone(), + json!({ "roomsTurnId": turn_id.formatted(), "status": "cancelling" }), + ); + Disposition::Handled + } + None => Disposition::Reject { + code: NO_ACTIVE_TURN_ERROR_CODE, + message: "no active turn to cancel".to_string(), + }, + } + } + "session/prompt" if ctx.prompt_in_flight().is_some() => { + let held_by = ctx + .prompt_in_flight() + .and_then(|mux_id| ctx.pending_peer(mux_id)); + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::session_busy(&room_id, true, held_by)); + Disposition::Reject { + code: SESSION_BUSY_ERROR_CODE, + message: "session busy: another turn is in flight".to_string(), + } + } + _ => { + if req.method != "initialize" { + self.note_driving_subscriber(ctx, peer_id); + } + Disposition::Forward + } + } + } + + fn on_request_translating( + &mut self, + ctx: &mut MuxCtx, + peer_id: &str, + mux_id: u64, + req: &mut IncomingRequest, + ) { + let is_prompt = req.method == "session/prompt"; + let turn_id = if is_prompt { + let turn_id = RoomsTurnId(self.next_rooms_turn_id); + self.next_rooms_turn_id += 1; + Some(turn_id) + } else { + None + }; + self.per_request.insert( + mux_id, + RoomsRequestData { + rooms_turn_id: turn_id, + queue_item_id: None, + decorate_session_list: req.method == "session/list", + }, + ); + if self.options.meta_propagate { + let (peer_name, role) = ctx + .subscriber(peer_id) + .map(|s| (s.peer_name.as_deref(), s.role.as_deref())) + .unwrap_or((None, None)); + inject_request_trace_metadata( + req, + RequestTrace { + peer_id, + peer_name, + role, + mux_id, + rooms_turn_id: turn_id, + }, + ); + } + } + + fn on_request_forwarded( + &mut self, + ctx: &mut MuxCtx, + peer_id: &str, + mux_id: u64, + req: &IncomingRequest, + ) { + let Some(data) = self.per_request.get(&mux_id) else { + return; + }; + let Some(turn_id) = data.rooms_turn_id else { + return; + }; + self.active_rooms_turn_id = Some(turn_id); + self.active_turn_session_id = req + .params + .as_ref() + .and_then(|p| p.get("sessionId")) + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| ctx.canonical_session_id().map(str::to_string)); + self.active_turn_prompt_text = req + .params + .as_ref() + .and_then(|p| p.get("prompt")) + .and_then(text_from_text_only_prompt); + self.emit_turn_started(ctx, peer_id, turn_id, req.params.as_ref(), None); + } + + fn on_subscriber_notification( + &mut self, + ctx: &mut MuxCtx, + peer_id: &str, + notif: &IncomingNotification, + ) -> NotifyDisposition { + if notif.method != rooms::METHOD_CANCEL_ACTIVE_TURN { + return NotifyDisposition::Passthrough; + } + // Notification shape: fire-and-forget. A missing active turn is a + // silent no-op (unlike the request shape, which returns -32002). + let reason = notif + .params + .as_ref() + .and_then(|v| v.get("reason")) + .and_then(Value::as_str); + self.cancel_active_turn(ctx, peer_id, reason); + NotifyDisposition::Handled + } + + fn on_agent_request(&mut self, ctx: &mut MuxCtx, id: &Id, req: &IncomingRequest) { + let request_id_value = serde_json::to_value(id).unwrap_or(Value::Null); + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::agent_request_opened( + &room_id, + &request_id_value, + &req.method, + req.params.as_ref(), + self.active_rooms_turn_id, + )); + } + + fn on_agent_response(&mut self, _ctx: &mut MuxCtx, mux_id: u64, resp: &mut IncomingResponse) { + if self + .per_request + .get(&mux_id) + .is_some_and(|data| data.decorate_session_list) + { + self.decorate_session_list_response(resp); + } + } + + fn on_prompt_settled(&mut self, ctx: &mut MuxCtx, mux_id: u64, resp: &IncomingResponse) { + let data = self.per_request.remove(&mux_id); + self.active_turn_session_id = None; + self.active_turn_prompt_text = None; + let turn_id = self + .active_rooms_turn_id + .take() + .or(data.as_ref().and_then(|d| d.rooms_turn_id)); + if let Some(turn_id) = turn_id { + self.emit_turn_complete(ctx, turn_id, resp.result.as_ref()); + if let Some(queue_item_id) = data.and_then(|d| d.queue_item_id) { + let stop_reason = resp + .result + .as_ref() + .and_then(|r| r.get("stopReason")) + .cloned() + .unwrap_or(Value::Null); + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::queue_item_completed( + &room_id, + &queue_item_id, + turn_id, + &stop_reason, + )); + } + } + self.submit_next_queued_prompt(ctx); + } + + fn on_agent_request_resolved( + &mut self, + ctx: &mut MuxCtx, + id: &Id, + by: ResolvedBy, + resp: Option<&IncomingResponse>, + ) { + let request_id_value = serde_json::to_value(id).unwrap_or(Value::Null); + let resolved_by = match by { + ResolvedBy::Peer(peer_id) => peer_id, + ResolvedBy::AgentCancelled => rooms::RESOLVED_BY_AGENT_CANCELLED.to_string(), + ResolvedBy::TurnEnded => "mux:turn-ended".to_string(), + }; + let error_value = resp + .and_then(|r| r.error.as_ref()) + .and_then(|e| serde_json::to_value(e).ok()); + let result = resp.and_then(|r| r.result.as_ref()); + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::agent_request_resolved( + &room_id, + &request_id_value, + &resolved_by, + result, + error_value.as_ref(), + )); + } + + fn on_canonical_session_id( + &mut self, + ctx: &mut MuxCtx, + _old: Option<&str>, + new: &str, + via_load: bool, + ) { + let active_already_matches = self + .active_segment() + .and_then(|segment| segment.acp_session_id.as_deref()) + == Some(new); + if !active_already_matches { + self.rotate_segment( + ctx, + Some(new.to_string()), + if via_load { + EndReason::SessionLoad + } else { + EndReason::AcpSessionIdChanged + }, + ); + } + if via_load { + self.replay_generation += 1; + let (dropped_frame_count, retained_frame_count) = self.replay_retention_counts(ctx); + self.last_replay_reset = Some(ReplayResetSnapshot { + loaded_session_id: new.to_string(), + replay_generation: self.replay_generation, + dropped_frame_count, + retained_frame_count, + }); + } + self.publish_session_list_metadata(ctx); + } + + fn on_subscriber_attaching( + &mut self, + ctx: &mut MuxCtx, + newcomer: &acp_mux::subscriber::Subscriber, + ) { + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::peer_joined( + &room_id, + &newcomer.peer_id, + newcomer.peer_name.as_deref(), + newcomer.role.as_deref(), + )); + } + + fn on_subscriber_attached(&mut self, ctx: &mut MuxCtx, peer_id: &str) { + let room_id = ctx.mux_id().to_string(); + ctx.send_to( + peer_id, + Bytes::from(rooms::session_context(&room_id, ctx.agent_cwd())), + ); + self.publish_session_list_metadata(ctx); + } + + fn on_subscriber_detached(&mut self, ctx: &mut MuxCtx, peer_id: &str) { + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::peer_left(&room_id, peer_id)); + let orphaned_queue_item_ids: Vec = self + .queued_prompts + .iter() + .filter(|item| item.peer_id == peer_id) + .filter_map(|item| item.queue_item_id.clone()) + .collect(); + for queue_item_id in orphaned_queue_item_ids { + ctx.broadcast(rooms::queue_item_orphaned( + &room_id, + &queue_item_id, + peer_id, + )); + } + if self.driving_subscriber_peer_id.as_deref() == Some(peer_id) { + self.driving_subscriber_peer_id = None; + } + if ctx.subscribers().next().is_none() { + self.clear_session_list_metadata(ctx); + } else { + self.publish_session_list_metadata(ctx); + } + } + + fn on_attach( + &mut self, + ctx: &mut MuxCtx, + peer_id: &str, + params: &AttachParams, + result: &mut AttachResult, + ) { + let replay_order = attach_meta_str(params, "replayOrder").unwrap_or("chronological"); + let requested_delivery = attach_meta_str(params, "historyDelivery").unwrap_or("response"); + let applied_delivery = if matches!(requested_delivery, "stream") + && !matches!(result.history_policy, acp_mux::attach::HistoryPolicy::None) + { + "stream" + } else { + "response" + }; + let connected_clients = std::mem::take(&mut result.connected_clients); + let replay_boundary_seq = ctx + .replay_entries() + .last() + .map(|entry| entry.seq) + .unwrap_or(0); + let self_peer = connected_clients + .iter() + .find(|client| client.client_id == peer_id) + .cloned() + .unwrap_or(acp_mux::attach::ConnectedClient { + client_id: peer_id.to_string(), + name: ctx.subscriber(peer_id).and_then(|s| s.peer_name.clone()), + }); + let pending_permissions: Vec = ctx + .pending_permissions() + .iter() + .filter_map(|(id, frame)| { + let request_id = serde_json::to_value(id).ok()?; + let value: Value = serde_json::from_slice(frame).ok()?; + Some(json!({ + "requestId": request_id, + "toolName": value.pointer("/params/toolCall/title").cloned(), + "summary": value.pointer("/params/toolCall/title").cloned(), + })) + }) + .collect(); + let snapshot = json!({ + "connectedClients": connected_clients, + "selfPeer": self_peer, + "activeTurn": self.active_rooms_turn_id.and_then(|turn_id| { + ctx.prompt_in_flight().and_then(|mux_id| { + ctx.pending_peer(mux_id).map(|peer_id| json!({ + "roomsTurnId": turn_id.formatted(), + "peerId": peer_id, + })) + }) + }), + "queue": self.queued_prompts.iter().map(|item| json!({ + "queueItemId": item.queue_item_id, + "peerId": item.peer_id, + "kind": match item.kind { + QueuedPromptKind::Prompt => "prompt", + QueuedPromptKind::Queue => "queue", + QueuedPromptKind::HardSteer { .. } => "hard_steer", + }, + "status": "queued", + })).collect::>(), + "pendingPermissions": pending_permissions, + "replayBoundarySeq": replay_boundary_seq, + "replayGeneration": self.replay_generation, + "segments": self.segments, + "activeSegmentId": self.active_segment_id, + }); + result.extra.insert( + "_meta".to_string(), + json!({ + "rooms": { + "connectedClients": snapshot["connectedClients"].clone(), + "appliedReplayOrder": replay_order, + "appliedHistoryDelivery": applied_delivery, + "snapshot": snapshot, + } + }), + ); + + match result.history_policy { + HistoryPolicy::Full => { + result.history = Some(self.replay_history(ctx, false)); + } + HistoryPolicy::FullLineage => { + result.history = Some(self.replay_history(ctx, true)); + } + HistoryPolicy::PendingOnly | HistoryPolicy::None | HistoryPolicy::AfterMessage => {} + } + + if let Some(history) = result.history.as_mut() + && replay_order == "newest_turn_first" + { + *history = newest_turn_first_history(std::mem::take(history)); + } + + if applied_delivery == "stream" { + let history = result.history.take().unwrap_or_default(); + let phases = replay_stream_phases( + peer_id, + replay_order, + self.replay_generation, + replay_boundary_seq, + history, + ); + schedule_wake_payload( + ctx, + std::time::Duration::from_millis(1), + WakePayload::ReplayStream { phases }, + ); + } + + if matches!(result.history_policy, HistoryPolicy::PendingOnly) { + let frames = ctx + .pending_permissions() + .iter() + .filter_map(|(_, frame)| std::str::from_utf8(frame).ok().map(str::to_string)) + .collect::>(); + if !frames.is_empty() { + schedule_wake_payload( + ctx, + std::time::Duration::from_millis(1), + WakePayload::PendingPermissions { + peer_id: peer_id.to_string(), + frames, + }, + ); + } + } + } + + fn replay_frame(&mut self, ctx: &mut MuxCtx, entry: ReplayView<'_>) -> Option { + if !self.should_include_replay_entry(ctx, entry.clone()) { + return None; + } + Some(inject_replay_metadata( + entry.frame, + entry.recorded_at, + entry.seq, + )) + } + + fn on_wake(&mut self, ctx: &mut MuxCtx, payload: Vec) { + let Ok(plan) = serde_json::from_slice::(&payload) else { + return; + }; + match plan { + WakePayload::ReplayStream { mut phases } => { + let Some(phase) = phases.first().cloned() else { + return; + }; + phases.remove(0); + self.send_replay_phase(ctx, &phase); + if !phases.is_empty() { + schedule_wake_payload( + ctx, + std::time::Duration::from_millis(50), + WakePayload::ReplayStream { phases }, + ); + } + } + WakePayload::PendingPermissions { peer_id, frames } => { + for frame in frames { + ctx.send_to(&peer_id, Bytes::from(frame)); + } + } + } + } + + fn debug_snapshot(&self, ctx: &MuxCtx) -> Value { + json!({ + "activeRoomsTurnId": self.active_rooms_turn_id.map(|t| t.formatted()), + "drivingSubscriber": self.driving_subscriber_peer_id, + "replayGeneration": self.replay_generation, + "lastReplayReset": self.last_replay_reset, + "replayLogUpdateFramesByAcpSessionId": self.replay_update_counts_by_session(ctx), + "nextRoomsTurnId": self.next_rooms_turn_id, + "segments": self.segments, + "activeSegmentId": self.active_segment_id, + }) + } +} + +pub(super) fn object_params(req: &mut IncomingRequest) -> Option<&mut Map> { + let params = req.params.get_or_insert_with(|| Value::Object(Map::new())); + match params { + Value::Object(map) => Some(map), + _ => None, + } +} + +pub(super) fn object_field<'a>( + object: &'a mut Map, + key: &str, +) -> Option<&'a mut Map> { + let value = object + .entry(key.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + match value { + Value::Object(map) => Some(map), + _ => None, + } +} + +pub(super) fn next_replay_seq(ctx: &MuxCtx) -> u64 { + ctx.replay_entries() + .last() + .map(|entry| entry.seq.saturating_add(1)) + .unwrap_or(1) +} + +pub(super) fn utc_rfc3339_now() -> String { + system_time_to_rfc3339_utc(SystemTime::now()) +} + +fn system_time_to_rfc3339_utc(time: SystemTime) -> String { + let duration = time.duration_since(UNIX_EPOCH).unwrap_or_default(); + let total_secs = duration.as_secs() as i64; + let days = total_secs.div_euclid(86_400); + let secs_of_day = total_secs.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + let hour = secs_of_day / 3_600; + let minute = (secs_of_day % 3_600) / 60; + let second = secs_of_day % 60; + format!( + "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{nanos:09}Z", + nanos = duration.subsec_nanos(), + ) +} + +fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) { + let z = days_since_epoch + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let mut year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = mp + if mp < 10 { 3 } else { -9 }; + if month <= 2 { + year += 1; + } + (year, month as u32, day as u32) +} diff --git a/crates/rooms/src/extension/presence.rs b/crates/rooms/src/extension/presence.rs new file mode 100644 index 0000000..f15bb33 --- /dev/null +++ b/crates/rooms/src/extension/presence.rs @@ -0,0 +1,92 @@ +//! Presence and session-list hooks for `RoomsExtension`. + +use super::*; + +impl RoomsExtension { + pub(super) fn note_driving_subscriber(&mut self, ctx: &mut MuxCtx, peer_id: &str) { + if ctx.subscriber(peer_id).is_none() { + return; + } + if self.driving_subscriber_peer_id.as_deref() != Some(peer_id) { + self.driving_subscriber_peer_id = Some(peer_id.to_string()); + self.publish_session_list_metadata(ctx); + } + } + + pub(super) fn publish_session_list_metadata(&self, ctx: &MuxCtx) { + let Some(acp_session_id) = ctx.canonical_session_id() else { + return; + }; + let subscriber_count = ctx.subscribers().count(); + if subscriber_count == 0 { + self.session_list_index + .remove_if_room(acp_session_id, ctx.mux_id()); + return; + } + self.session_list_index.upsert( + acp_session_id, + SessionListRoomsMetadata { + room_id: ctx.mux_id().to_string(), + subscriber_count, + driving_subscriber: self.driving_subscriber_peer_id.clone(), + }, + ); + } + + pub(super) fn clear_session_list_metadata(&self, ctx: &MuxCtx) { + if let Some(acp_session_id) = ctx.canonical_session_id() { + self.session_list_index + .remove_if_room(acp_session_id, ctx.mux_id()); + } + } + + pub(super) fn decorate_session_list_response(&self, resp: &mut IncomingResponse) { + let Some(result) = resp.result.as_mut() else { + return; + }; + let Some(sessions) = result.get_mut("sessions").and_then(Value::as_array_mut) else { + return; + }; + for session in sessions { + let Some(acp_session_id) = session + .get("sessionId") + .and_then(Value::as_str) + .map(str::to_string) + else { + continue; + }; + let Some(metadata) = self.session_list_index.get(&acp_session_id) else { + continue; + }; + inject_session_list_rooms_metadata(session, &metadata); + } + } +} + +fn inject_session_list_rooms_metadata(session: &mut Value, metadata: &SessionListRoomsMetadata) { + let Value::Object(session) = session else { + return; + }; + let Some(meta) = object_field(session, "_meta") else { + return; + }; + let Some(rooms) = object_field(meta, "rooms") else { + return; + }; + rooms.insert( + "roomId".to_string(), + Value::String(metadata.room_id.clone()), + ); + rooms.insert( + "subscriberCount".to_string(), + Value::Number(serde_json::Number::from(metadata.subscriber_count)), + ); + if let Some(driving_subscriber) = metadata.driving_subscriber.as_ref() { + rooms.insert( + "drivingSubscriber".to_string(), + Value::String(driving_subscriber.clone()), + ); + } else { + rooms.remove("drivingSubscriber"); + } +} diff --git a/crates/rooms/src/extension/queue.rs b/crates/rooms/src/extension/queue.rs new file mode 100644 index 0000000..a4743f6 --- /dev/null +++ b/crates/rooms/src/extension/queue.rs @@ -0,0 +1,527 @@ +//! Queue and steering hooks for `RoomsExtension`. + +use super::*; + +#[derive(Debug)] +struct ActiveControlParams { + session_id: String, + text: String, +} + +impl RoomsExtension { + fn parse_rooms_active_turn_control_params( + &self, + ctx: &mut MuxCtx, + peer_id: &str, + req: &IncomingRequest, + require_active_turn: bool, + ) -> Option { + if require_active_turn && ctx.prompt_in_flight().is_none() { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + NO_ACTIVE_TURN_ERROR_CODE, + "rooms active-turn control requires an active turn", + ); + return None; + } + + let Some(Value::Object(params)) = req.params.as_ref() else { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + INVALID_PARAMS_ERROR_CODE, + "rooms control params must be an object", + ); + return None; + }; + + let text = match params.get("text") { + Some(Value::String(text)) => text.clone(), + Some(_) => { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + INVALID_PARAMS_ERROR_CODE, + "rooms control params.text must be a string", + ); + return None; + } + None => match params.get("prompt").and_then(text_from_text_only_prompt) { + Some(text) => text, + None => { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + INVALID_PARAMS_ERROR_CODE, + "rooms control params.text or text-only params.prompt is required", + ); + return None; + } + }, + }; + let text = text.trim(); + if text.is_empty() { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + INVALID_PARAMS_ERROR_CODE, + "rooms control text must be non-empty", + ); + return None; + } + + let requested_session_id = match params.get("sessionId") { + Some(Value::String(session_id)) => Some(session_id.clone()), + Some(_) => { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + INVALID_PARAMS_ERROR_CODE, + "rooms control params.sessionId must be a string when present", + ); + return None; + } + None => None, + }; + let active_session_id = self + .active_turn_session_id + .clone() + .or_else(|| ctx.canonical_session_id().map(str::to_string)); + if let (Some(requested), Some(active)) = (&requested_session_id, &active_session_id) + && requested != active + { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + INVALID_PARAMS_ERROR_CODE, + "rooms control params.sessionId must match the active or canonical sessionId", + ); + return None; + } + let Some(session_id) = requested_session_id.or(active_session_id) else { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + INVALID_PARAMS_ERROR_CODE, + "rooms control could not determine an ACP sessionId", + ); + return None; + }; + + Some(ActiveControlParams { + session_id, + text: text.to_string(), + }) + } + + fn pending_queue_prompt_count(&self) -> usize { + self.queued_prompts + .iter() + .filter(|item| matches!(item.kind, QueuedPromptKind::Queue)) + .count() + } + + fn has_pending_hard_steer(&self) -> bool { + self.queued_prompts + .iter() + .any(|item| matches!(item.kind, QueuedPromptKind::HardSteer { .. })) + } + + pub(super) fn handle_rooms_queue_prompt_request( + &mut self, + ctx: &mut MuxCtx, + peer_id: &str, + req: &IncomingRequest, + ) -> Disposition { + let Some(control) = self.parse_rooms_active_turn_control_params(ctx, peer_id, req, false) + else { + return Disposition::Handled; + }; + if self.pending_queue_prompt_count() >= MAX_MUX_QUEUE_PROMPTS { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + QUEUE_FULL_ERROR_CODE, + "queue full", + ); + return Disposition::Handled; + } + let submit_immediately = ctx.prompt_in_flight().is_none(); + let queue_item_id = format!("q-{}", self.next_queue_item_id); + self.next_queue_item_id += 1; + let (peer_name, role) = ctx + .subscriber(peer_id) + .map(|s| (s.peer_name.as_deref(), s.role.as_deref())) + .unwrap_or((None, None)); + self.queued_prompts.push_back(QueuedPrompt { + queue_item_id: Some(queue_item_id.clone()), + peer_id: peer_id.to_string(), + session_id: control.session_id, + prompt_text: control.text.clone(), + kind: QueuedPromptKind::Queue, + }); + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::queue_item_added( + &room_id, + &queue_item_id, + peer_id, + peer_name, + role, + &control.text, + )); + let submitted = submit_immediately && self.submit_next_queued_prompt(ctx).is_some(); + let status = if submitted { "submitted" } else { "queued" }; + self.send_result_response( + ctx, + peer_id, + req.id.clone(), + json!({ "queueItemId": queue_item_id, "status": status }), + ); + Disposition::Handled + } + + pub(super) fn handle_rooms_unqueue_prompt_request( + &mut self, + ctx: &mut MuxCtx, + peer_id: &str, + req: &IncomingRequest, + ) -> Disposition { + let Some(Value::Object(params)) = req.params.as_ref() else { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + INVALID_PARAMS_ERROR_CODE, + "rooms/unqueue_prompt params must be an object", + ); + return Disposition::Handled; + }; + let Some(queue_item_id) = params.get("queueItemId").and_then(Value::as_str) else { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + INVALID_PARAMS_ERROR_CODE, + "rooms/unqueue_prompt params.queueItemId must be a string", + ); + return Disposition::Handled; + }; + let queue_item_id = queue_item_id.trim().to_string(); + if queue_item_id.is_empty() { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + INVALID_PARAMS_ERROR_CODE, + "rooms/unqueue_prompt params.queueItemId must be non-empty", + ); + return Disposition::Handled; + } + let Some(position) = self + .queued_prompts + .iter() + .position(|item| item.queue_item_id.as_deref() == Some(queue_item_id.as_str())) + else { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + QUEUE_ITEM_NOT_FOUND_ERROR_CODE, + "queue item not found", + ); + return Disposition::Handled; + }; + self.queued_prompts.remove(position); + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::queue_item_removed(&room_id, &queue_item_id, peer_id)); + self.send_result_response( + ctx, + peer_id, + req.id.clone(), + json!({ "queueItemId": queue_item_id, "status": "removed" }), + ); + Disposition::Handled + } + + pub(super) fn handle_rooms_steer_request( + &mut self, + ctx: &mut MuxCtx, + peer_id: &str, + req: &IncomingRequest, + ) -> Disposition { + let Some(control) = self.parse_rooms_active_turn_control_params(ctx, peer_id, req, false) + else { + return Disposition::Handled; + }; + if ctx.prompt_in_flight().is_none() { + return self.handle_rooms_idle_steer_request(ctx, peer_id, req.id.clone(), control); + } + if self.has_pending_hard_steer() { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + NO_ACTIVE_TURN_ERROR_CODE, + "a hard steer is already pending for this turn", + ); + return Disposition::Handled; + } + let supersedes_turn_id = match self.active_rooms_turn_id { + Some(turn_id) => turn_id, + None => return Disposition::Handled, + }; + let Some(active_session_id) = self.active_turn_session_id.clone() else { + self.send_error_response( + ctx, + peer_id, + req.id.clone(), + INVALID_PARAMS_ERROR_CODE, + "rooms control could not determine the active ACP sessionId", + ); + return Disposition::Handled; + }; + let original_driver = ctx + .prompt_in_flight() + .and_then(|mux_id| ctx.pending_peer(mux_id).map(str::to_string)) + .unwrap_or_else(|| peer_id.to_string()); + let original_prompt = self.active_turn_prompt_text.as_deref(); + let replacement_prompt = + build_hard_steer_prompt(peer_id, supersedes_turn_id, original_prompt, &control.text); + let (peer_name, role) = ctx + .subscriber(peer_id) + .map(|s| (s.peer_name.as_deref(), s.role.as_deref())) + .unwrap_or((None, None)); + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::control_submitted(rooms::ControlSubmitted { + room_id: &room_id, + kind: "steer", + mode: "hard", + peer_id, + peer_name, + role, + rooms_turn_id: Some(supersedes_turn_id), + text: &control.text, + })); + ctx.broadcast(rooms::turn_cancelled( + &room_id, + supersedes_turn_id, + peer_id, + &original_driver, + Some("hard_steer"), + )); + self.queued_prompts.push_front(QueuedPrompt { + queue_item_id: None, + peer_id: peer_id.to_string(), + session_id: control.session_id, + prompt_text: replacement_prompt, + kind: QueuedPromptKind::HardSteer { supersedes_turn_id }, + }); + self.send_result_response( + ctx, + peer_id, + req.id.clone(), + json!({ + "accepted": true, + "mode": "hard", + "supersedesTurnId": supersedes_turn_id.formatted(), + }), + ); + ctx.send_to_agent(build_session_cancel(&active_session_id)); + Disposition::Handled + } + + fn handle_rooms_idle_steer_request( + &mut self, + ctx: &mut MuxCtx, + peer_id: &str, + req_id: Id, + control: ActiveControlParams, + ) -> Disposition { + let turn_id = RoomsTurnId(self.next_rooms_turn_id); + let (peer_name, role) = ctx + .subscriber(peer_id) + .map(|s| (s.peer_name.as_deref(), s.role.as_deref())) + .unwrap_or((None, None)); + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::control_submitted(rooms::ControlSubmitted { + room_id: &room_id, + kind: "steer", + mode: "prompt", + peer_id, + peer_name, + role, + rooms_turn_id: Some(turn_id), + text: &control.text, + })); + self.queued_prompts.push_front(QueuedPrompt { + queue_item_id: None, + peer_id: peer_id.to_string(), + session_id: control.session_id, + prompt_text: control.text, + kind: QueuedPromptKind::Prompt, + }); + self.submit_next_queued_prompt(ctx); + self.send_result_response( + ctx, + peer_id, + req_id, + json!({ + "accepted": true, + "mode": "prompt", + "status": "submitted", + "roomsTurnId": turn_id.formatted(), + }), + ); + Disposition::Handled + } + + pub(super) fn submit_next_queued_prompt(&mut self, ctx: &mut MuxCtx) -> Option { + let item = self.queued_prompts.pop_front()?; + self.note_driving_subscriber(ctx, &item.peer_id); + let turn_id = RoomsTurnId(self.next_rooms_turn_id); + self.next_rooms_turn_id += 1; + let supersedes_turn_id = match item.kind { + QueuedPromptKind::Prompt | QueuedPromptKind::Queue => None, + QueuedPromptKind::HardSteer { supersedes_turn_id } => Some(supersedes_turn_id), + }; + let queue_item_id = item.queue_item_id.clone(); + let params = json!({ + "sessionId": item.session_id, + "prompt": [{ "type": "text", "text": item.prompt_text }], + }); + let mux_id = ctx.submit_prompt(&item.peer_id, params.clone(), false); + if mux_id == 0 { + return None; + } + self.per_request.insert( + mux_id, + RoomsRequestData { + rooms_turn_id: Some(turn_id), + queue_item_id: queue_item_id.clone(), + decorate_session_list: false, + }, + ); + self.active_rooms_turn_id = Some(turn_id); + self.active_turn_session_id = params + .get("sessionId") + .and_then(Value::as_str) + .map(str::to_string); + self.active_turn_prompt_text = params.get("prompt").and_then(text_from_text_only_prompt); + self.emit_turn_started( + ctx, + &item.peer_id, + turn_id, + Some(¶ms), + supersedes_turn_id, + ); + if let Some(queue_item_id) = queue_item_id.as_deref() { + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::queue_item_submitted( + &room_id, + queue_item_id, + turn_id, + )); + } + Some(mux_id) + } +} + +pub(super) fn text_from_text_only_prompt(prompt: &Value) -> Option { + let prompt = prompt.as_array()?; + if prompt.is_empty() { + return None; + } + let mut text = String::new(); + for block in prompt { + let block_type = block.get("type").and_then(Value::as_str)?; + if block_type != "text" { + return None; + } + let block_text = block.get("text").and_then(Value::as_str)?; + text.push_str(block_text); + } + Some(text) +} + +fn build_hard_steer_prompt( + peer_id: &str, + supersedes_turn_id: RoomsTurnId, + original_prompt: Option<&str>, + steering_text: &str, +) -> String { + let original_prompt = original_prompt.unwrap_or("(unavailable/non-text)"); + format!( + "Active turn steered by peer `{peer_id}` (supersedes {supersedes}). Use the steer below to answer the original prompt.\n\nOriginal:\n{original_prompt}\n\nSteer:\n{steering_text}", + supersedes = supersedes_turn_id.formatted(), + ) +} + +pub(super) fn build_session_cancel(session_id: &str) -> Vec { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct CancelParams<'a> { + session_id: &'a str, + } + #[derive(serde::Serialize)] + struct CancelFrame<'a> { + jsonrpc: &'static str, + method: &'static str, + params: CancelParams<'a>, + } + serde_json::to_vec(&CancelFrame { + jsonrpc: "2.0", + method: SESSION_CANCEL_METHOD, + params: CancelParams { session_id }, + }) + .expect("session/cancel frame is always serializable") +} + +pub(super) struct RequestTrace<'a> { + pub(super) peer_id: &'a str, + pub(super) peer_name: Option<&'a str>, + pub(super) role: Option<&'a str>, + pub(super) mux_id: u64, + pub(super) rooms_turn_id: Option, +} + +pub(super) fn inject_request_trace_metadata(req: &mut IncomingRequest, trace: RequestTrace<'_>) { + let Some(params) = object_params(req) else { + return; + }; + let Some(meta) = object_field(params, "_meta") else { + return; + }; + let Some(rooms) = object_field(meta, "rooms") else { + return; + }; + rooms.insert( + "peerId".to_string(), + Value::String(trace.peer_id.to_string()), + ); + if let Some(peer_name) = trace.peer_name { + rooms.insert("peerName".to_string(), Value::String(peer_name.to_string())); + } + if let Some(role) = trace.role { + rooms.insert("role".to_string(), Value::String(role.to_string())); + } + rooms.insert( + "muxId".to_string(), + Value::Number(serde_json::Number::from(trace.mux_id)), + ); + if let Some(turn_id) = trace.rooms_turn_id { + rooms.insert( + "roomsTurnId".to_string(), + Value::String(turn_id.formatted()), + ); + } +} diff --git a/crates/rooms/src/extension/segments.rs b/crates/rooms/src/extension/segments.rs new file mode 100644 index 0000000..d8448df --- /dev/null +++ b/crates/rooms/src/extension/segments.rs @@ -0,0 +1,79 @@ +//! Segment and replay-generation hooks for `RoomsExtension`. + +use super::*; + +impl RoomsExtension { + pub(super) fn rotate_segment( + &mut self, + ctx: &mut MuxCtx, + new_acp_session_id: Option, + reason: EndReason, + ) { + let now = utc_rfc3339_now(); + let room_id = ctx.mux_id().to_string(); + + let Some(current_id) = self.active_segment_id else { + let id = SegmentId(self.next_segment_id); + self.next_segment_id = self.next_segment_id.saturating_add(1); + let mut seg = Segment::open(id, new_acp_session_id.clone(), next_replay_seq(ctx)); + seg.opened_at = now.clone(); + self.segments.push(seg); + self.active_segment_id = Some(id); + ctx.set_replay_tag(id.0); + if self.options.emit_segment_frames { + ctx.broadcast(rooms::segment_started( + &room_id, + id, + new_acp_session_id.as_deref(), + &now, + )); + } + return; + }; + + if let Some(seg) = self.segments.iter_mut().find(|s| s.id == current_id) { + seg.closed_at = Some(now.clone()); + seg.end_reason = Some(reason); + } + let new_id = SegmentId(self.next_segment_id); + self.next_segment_id = self.next_segment_id.saturating_add(1); + ctx.set_replay_tag(current_id.0); + if self.options.emit_segment_frames { + ctx.broadcast(rooms::segment_ended( + &room_id, + current_id, + &now, + reason, + Some(new_id), + )); + } + let closed_replay_seq = next_replay_seq(ctx).saturating_sub(1); + if let Some(seg) = self.segments.iter_mut().find(|s| s.id == current_id) { + seg.closed_replay_seq = Some(closed_replay_seq); + } + for item in &mut self.queued_prompts { + if let Some(new_acp) = new_acp_session_id.as_deref() { + item.session_id = new_acp.to_string(); + } + } + let mut new_segment = + Segment::open(new_id, new_acp_session_id.clone(), next_replay_seq(ctx)); + new_segment.opened_at = now.clone(); + self.segments.push(new_segment); + self.active_segment_id = Some(new_id); + ctx.set_replay_tag(new_id.0); + if self.options.emit_segment_frames { + ctx.broadcast(rooms::segment_started( + &room_id, + new_id, + new_acp_session_id.as_deref(), + &now, + )); + } + } + + pub(super) fn active_segment(&self) -> Option<&Segment> { + let id = self.active_segment_id?; + self.segments.iter().find(|s| s.id == id) + } +} diff --git a/crates/rooms/src/extension/turns.rs b/crates/rooms/src/extension/turns.rs new file mode 100644 index 0000000..07237e8 --- /dev/null +++ b/crates/rooms/src/extension/turns.rs @@ -0,0 +1,43 @@ +//! Turn lifecycle hooks for `RoomsExtension`. + +use super::*; + +impl RoomsExtension { + pub(super) fn emit_turn_started( + &mut self, + ctx: &mut MuxCtx, + peer_id: &str, + turn_id: RoomsTurnId, + params: Option<&Value>, + supersedes_turn_id: Option, + ) { + let null = Value::Null; + let content = params.and_then(|p| p.get("prompt")).unwrap_or(&null); + let (peer_name, role) = ctx + .subscriber(peer_id) + .map(|s| (s.peer_name.clone(), s.role.clone())) + .unwrap_or((None, None)); + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::turn_started( + &room_id, + turn_id, + peer_id, + peer_name.as_deref(), + role.as_deref(), + content, + supersedes_turn_id, + )); + } + + pub(super) fn emit_turn_complete( + &mut self, + ctx: &mut MuxCtx, + turn_id: RoomsTurnId, + result: Option<&Value>, + ) { + let null = Value::Null; + let stop_reason = result.and_then(|r| r.get("stopReason")).unwrap_or(&null); + let room_id = ctx.mux_id().to_string(); + ctx.broadcast(rooms::turn_complete(&room_id, turn_id, stop_reason)); + } +} diff --git a/src/lib.rs b/crates/rooms/src/lib.rs similarity index 81% rename from src/lib.rs rename to crates/rooms/src/lib.rs index 202bf6f..4749ded 100644 --- a/src/lib.rs +++ b/crates/rooms/src/lib.rs @@ -1,7 +1,6 @@ -#![allow(dead_code)] - pub mod agent; pub mod cli; +pub mod extension; pub mod multiplex; pub mod protocol; pub mod room; diff --git a/src/main.rs b/crates/rooms/src/main.rs similarity index 90% rename from src/main.rs rename to crates/rooms/src/main.rs index 34875c9..54ef7ea 100644 --- a/src/main.rs +++ b/crates/rooms/src/main.rs @@ -1,12 +1,12 @@ use std::net::SocketAddr; use std::sync::Arc; -use amux::cli; -use amux::room::registry::{AgentCmd, RoomRegistry}; -use amux::room::replay_store::ReplayStore; -use amux::server; use anyhow::{Context, Result}; use clap::Parser; +use rooms::cli; +use rooms::room::registry::{AgentCmd, RoomRegistry}; +use rooms::room::replay_store::ReplayStore; +use rooms::server; use tracing_subscriber::EnvFilter; #[tokio::main] @@ -44,7 +44,7 @@ async fn main() -> Result<()> { tracing::warn!( "--replay-store is enabled with --emit-segment-frames=false; \ restart hydration cannot reconstruct the canonical ACP session id or segment lineage \ - without persisted amux/segment_started bookends. Late joiners after a restart will \ + without persisted rooms/segment_started bookends. Late joiners after a restart will \ need to drive a fresh session/new or session/load before session/attach.", ); } diff --git a/crates/rooms/src/multiplex/mod.rs b/crates/rooms/src/multiplex/mod.rs new file mode 100644 index 0000000..b916aa1 --- /dev/null +++ b/crates/rooms/src/multiplex/mod.rs @@ -0,0 +1 @@ +pub use acp_mux::subscriber; diff --git a/src/protocol/attach.rs b/crates/rooms/src/protocol/attach.rs similarity index 89% rename from src/protocol/attach.rs rename to crates/rooms/src/protocol/attach.rs index 8dedab4..fe1937a 100644 --- a/src/protocol/attach.rs +++ b/crates/rooms/src/protocol/attach.rs @@ -1,16 +1,16 @@ //! RFD #533-inspired `session/attach` and `session/detach` request/response shapes. //! -//! amux handles these proxy-local methods itself. They are logical +//! rooms handles these proxy-local methods itself. They are logical //! ACP handshakes layered on top of the existing WebSocket attach query: //! the transport peer already exists, and `session/attach` returns optional -//! replay history shaped by `historyPolicy` and `_meta.amux.replayOrder`. -//! amux-specific peer metadata lives -//! under `result._meta.amux` so the top-level result remains standards-shaped. +//! replay history shaped by `historyPolicy` and `_meta.rooms.replayOrder`. +//! rooms-specific peer metadata lives +//! under `result._meta.rooms` so the top-level result remains standards-shaped. use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::protocol::amux::{EndReason, SegmentId}; +use crate::protocol::rooms::{EndReason, SegmentId}; pub const METHOD_ATTACH: &str = "session/attach"; pub const METHOD_DETACH: &str = "session/detach"; @@ -22,9 +22,9 @@ pub const ATTACH_ERR_UNSUPPORTED: i64 = -32003; #[serde(rename_all = "snake_case")] pub enum HistoryPolicy { /// Frames recorded in the current segment plus pre-segment - /// bootstrap. May also carry `amux/turn_started`, - /// `amux/turn_complete`, and `amux/turn_cancelled` frames from a - /// prior segment when their `amuxTurnId` brackets the active + /// bootstrap. May also carry `rooms/turn_started`, + /// `rooms/turn_complete`, and `rooms/turn_cancelled` frames from a + /// prior segment when their `roomsTurnId` brackets the active /// segment — that keeps mid-rotation turns properly bracketed for /// clients without leaking pre-rotation agent chunks. Preserves /// the v0.1.x default for clients that haven't opted into lineage. @@ -35,7 +35,7 @@ pub enum HistoryPolicy { FullLineage, PendingOnly, None, - /// Depends on stable ACP message IDs; amux currently accepts it and + /// Depends on stable ACP message IDs; rooms currently accepts it and /// falls back to `Full` when it cannot resolve `afterMessageId`. AfterMessage, } @@ -77,12 +77,12 @@ pub struct AttachParams { #[serde(rename_all = "camelCase")] pub struct AttachParamsMeta { #[serde(default)] - pub amux: Option, + pub rooms: Option, } #[derive(Debug, Clone, Deserialize, Default)] #[serde(rename_all = "camelCase")] -pub struct AttachParamsAmuxMeta { +pub struct AttachParamsRoomsMeta { #[serde(default)] pub replay_order: Option, #[serde(default)] @@ -112,12 +112,12 @@ pub struct AttachResult { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AttachMeta { - pub amux: AttachAmuxMeta, + pub rooms: AttachRoomsMeta, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AttachAmuxMeta { +pub struct AttachRoomsMeta { pub connected_clients: Vec, pub applied_replay_order: ReplayOrder, pub applied_history_delivery: HistoryDelivery, @@ -172,7 +172,7 @@ pub struct SegmentSummary { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AttachActiveTurn { - pub amux_turn_id: String, + pub rooms_turn_id: String, pub peer_id: String, } diff --git a/crates/rooms/src/protocol/mod.rs b/crates/rooms/src/protocol/mod.rs new file mode 100644 index 0000000..e03e913 --- /dev/null +++ b/crates/rooms/src/protocol/mod.rs @@ -0,0 +1,3 @@ +pub mod attach; +pub mod rooms; +pub use acp_mux::jsonrpc; diff --git a/src/protocol/amux.rs b/crates/rooms/src/protocol/rooms.rs similarity index 81% rename from src/protocol/amux.rs rename to crates/rooms/src/protocol/rooms.rs index 9c2fadb..b0f3cfb 100644 --- a/src/protocol/amux.rs +++ b/crates/rooms/src/protocol/rooms.rs @@ -1,66 +1,66 @@ -//! `amux/*` namespace frames — out-of-band metadata emitted by the +//! `rooms/*` namespace frames — out-of-band metadata emitted by the //! multiplexer (peer presence, turn boundaries, busy state). //! //! Each builder returns a complete JSON-RPC notification frame as NDJSON- //! ready bytes (no trailing newline; the WS-out / replay-log path adds -//! framing as needed). The shapes match `docs/design/amux-namespace.md`. +//! framing as needed). The shapes match `docs/design/rooms-namespace.md`. //! -//! `amuxTurnId` is formatted `at-` with a monotonic per-session +//! `roomsTurnId` is formatted `at-` with a monotonic per-session //! counter; the prefix exists so a token shows its origin in logs. use serde::Serialize; -const METHOD_PEER_JOINED: &str = "amux/peer_joined"; -const METHOD_PEER_LEFT: &str = "amux/peer_left"; -const METHOD_SESSION_CONTEXT: &str = "amux/session_context"; -const METHOD_TURN_STARTED: &str = "amux/turn_started"; -const METHOD_TURN_COMPLETE: &str = "amux/turn_complete"; -const METHOD_SESSION_BUSY: &str = "amux/session_busy"; -const METHOD_AGENT_REQUEST_OPENED: &str = "amux/agent_request_opened"; -const METHOD_AGENT_REQUEST_RESOLVED: &str = "amux/agent_request_resolved"; -const METHOD_TURN_CANCELLED: &str = "amux/turn_cancelled"; -const METHOD_CONTROL_SUBMITTED: &str = "amux/control_submitted"; -const METHOD_QUEUE_ITEM_ADDED: &str = "amux/queue_item_added"; -const METHOD_QUEUE_ITEM_SUBMITTED: &str = "amux/queue_item_submitted"; -const METHOD_QUEUE_ITEM_COMPLETED: &str = "amux/queue_item_completed"; -const METHOD_QUEUE_ITEM_REMOVED: &str = "amux/queue_item_removed"; -const METHOD_QUEUE_ITEM_ORPHANED: &str = "amux/queue_item_orphaned"; -const METHOD_REPLAY_STARTED: &str = "amux/replay_started"; -const METHOD_REPLAY_COMPLETE: &str = "amux/replay_complete"; -const METHOD_SEGMENT_STARTED: &str = "amux/segment_started"; -const METHOD_SEGMENT_ENDED: &str = "amux/segment_ended"; -/// Method name for the amux extension that lets any attached peer steer the +const METHOD_PEER_JOINED: &str = "rooms/peer_joined"; +const METHOD_PEER_LEFT: &str = "rooms/peer_left"; +const METHOD_SESSION_CONTEXT: &str = "rooms/session_context"; +const METHOD_TURN_STARTED: &str = "rooms/turn_started"; +const METHOD_TURN_COMPLETE: &str = "rooms/turn_complete"; +const METHOD_SESSION_BUSY: &str = "rooms/session_busy"; +const METHOD_AGENT_REQUEST_OPENED: &str = "rooms/agent_request_opened"; +const METHOD_AGENT_REQUEST_RESOLVED: &str = "rooms/agent_request_resolved"; +const METHOD_TURN_CANCELLED: &str = "rooms/turn_cancelled"; +const METHOD_CONTROL_SUBMITTED: &str = "rooms/control_submitted"; +const METHOD_QUEUE_ITEM_ADDED: &str = "rooms/queue_item_added"; +const METHOD_QUEUE_ITEM_SUBMITTED: &str = "rooms/queue_item_submitted"; +const METHOD_QUEUE_ITEM_COMPLETED: &str = "rooms/queue_item_completed"; +const METHOD_QUEUE_ITEM_REMOVED: &str = "rooms/queue_item_removed"; +const METHOD_QUEUE_ITEM_ORPHANED: &str = "rooms/queue_item_orphaned"; +const METHOD_REPLAY_STARTED: &str = "rooms/replay_started"; +const METHOD_REPLAY_COMPLETE: &str = "rooms/replay_complete"; +const METHOD_SEGMENT_STARTED: &str = "rooms/segment_started"; +const METHOD_SEGMENT_ENDED: &str = "rooms/segment_ended"; +/// Method name for the rooms extension that lets any attached peer steer the /// current composer state. If a turn is in flight, current mux-owned semantics /// cancel/supersede the active turn and submit a replacement prompt after the /// agent settles; if the mux is idle, the steer text is submitted immediately /// as the next prompt. -pub const METHOD_STEER_ACTIVE_TURN: &str = "amux/steer_active_turn"; +pub const METHOD_STEER_ACTIVE_TURN: &str = "rooms/steer_active_turn"; -/// Method name for the amux extension that asks the mux to enqueue text for +/// Method name for the rooms extension that asks the mux to enqueue text for /// the next turn while another turn is active. -pub const METHOD_QUEUE_PROMPT: &str = "amux/queue_prompt"; +pub const METHOD_QUEUE_PROMPT: &str = "rooms/queue_prompt"; -/// Method name for the amux extension that removes a queued prompt before it +/// Method name for the rooms extension that removes a queued prompt before it /// is submitted. The queue item must still be pending; active/already-complete /// items are not removable through this control path. -pub const METHOD_UNQUEUE_PROMPT: &str = "amux/unqueue_prompt"; +pub const METHOD_UNQUEUE_PROMPT: &str = "rooms/unqueue_prompt"; -/// Method name for the amux extension that lets any attached peer cancel +/// Method name for the rooms extension that lets any attached peer cancel /// the in-flight turn (not just the driver). Internally resolves to ACP /// `session/cancel` toward the agent; strict `$/cancel_request` /// semantics remain reserved for request-id cancellation. -pub const METHOD_CANCEL_ACTIVE_TURN: &str = "amux/cancel_active_turn"; +pub const METHOD_CANCEL_ACTIVE_TURN: &str = "rooms/cancel_active_turn"; -/// Resolved-by sentinel used in `amux/agent_request_resolved` cleanup +/// Resolved-by sentinel used in `rooms/agent_request_resolved` cleanup /// broadcasts when the agent itself cancels an agent-initiated request /// via `$/cancel_request`. Companion to the existing `"mux:turn-ended"` /// sentinel used by the turn-end sweep. pub const RESOLVED_BY_AGENT_CANCELLED: &str = "agent:cancelled"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct AmuxTurnId(pub u64); +pub struct RoomsTurnId(pub u64); -impl AmuxTurnId { +impl RoomsTurnId { pub fn formatted(self) -> String { format!("at-{}", self.0) } @@ -90,7 +90,7 @@ impl serde::Serialize for SegmentId { } } -/// Why a segment closed. Captured on `amux/segment_ended` and exposed via +/// Why a segment closed. Captured on `rooms/segment_ended` and exposed via /// `/debug/sessions` so operators can distinguish explicit client-driven /// loads from upstream session id changes observed in agent output. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -139,7 +139,7 @@ struct SessionContextParams<'a> { #[serde(rename_all = "camelCase")] struct TurnStartedParams<'a> { room_id: &'a str, - amux_turn_id: &'a str, + rooms_turn_id: &'a str, peer_id: &'a str, #[serde(skip_serializing_if = "Option::is_none")] peer_name: Option<&'a str>, @@ -154,7 +154,7 @@ struct TurnStartedParams<'a> { #[serde(rename_all = "camelCase")] struct TurnCompleteParams<'a> { room_id: &'a str, - amux_turn_id: &'a str, + rooms_turn_id: &'a str, stop_reason: &'a serde_json::Value, } @@ -181,7 +181,7 @@ struct AgentRequestOpenedParams<'a> { #[serde(skip_serializing_if = "Option::is_none")] request_params: Option<&'a serde_json::Value>, #[serde(skip_serializing_if = "Option::is_none")] - amux_turn_id: Option<&'a str>, + rooms_turn_id: Option<&'a str>, } /// Sibling of an agent-initiated request that the multiplexer broadcast @@ -203,7 +203,7 @@ struct AgentRequestResolvedParams<'a> { } /// Intent broadcast emitted when *any* attached peer triggers -/// `amux/cancel_active_turn`. Distinct from `amux/turn_complete`, which +/// `rooms/cancel_active_turn`. Distinct from `rooms/turn_complete`, which /// fires later when the agent actually returns the (possibly partial) /// response. The pair lets peers distinguish "stop was clicked" from /// "turn finished," and the `cancelledBy` / `originalDriver` fields @@ -213,7 +213,7 @@ struct AgentRequestResolvedParams<'a> { #[serde(rename_all = "camelCase")] struct TurnCancelledParams<'a> { room_id: &'a str, - amux_turn_id: &'a str, + rooms_turn_id: &'a str, cancelled_by: &'a str, original_driver: &'a str, #[serde(skip_serializing_if = "Option::is_none")] @@ -232,7 +232,7 @@ struct ControlSubmittedParams<'a> { #[serde(skip_serializing_if = "Option::is_none")] role: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] - amux_turn_id: Option<&'a str>, + rooms_turn_id: Option<&'a str>, text: &'a str, } @@ -243,7 +243,7 @@ pub struct ControlSubmitted<'a> { pub peer_id: &'a str, pub peer_name: Option<&'a str>, pub role: Option<&'a str>, - pub amux_turn_id: Option, + pub rooms_turn_id: Option, pub text: &'a str, } @@ -266,7 +266,7 @@ struct QueueItemAddedParams<'a> { struct QueueItemSubmittedParams<'a> { room_id: &'a str, queue_item_id: &'a str, - amux_turn_id: &'a str, + rooms_turn_id: &'a str, } #[derive(Serialize)] @@ -274,7 +274,7 @@ struct QueueItemSubmittedParams<'a> { struct QueueItemCompletedParams<'a> { room_id: &'a str, queue_item_id: &'a str, - amux_turn_id: &'a str, + rooms_turn_id: &'a str, stop_reason: &'a serde_json::Value, } @@ -312,7 +312,7 @@ fn encode(method: &'static str, params: P) -> Vec { method, params, }) - .expect("amux frame is always serializable") + .expect("rooms frame is always serializable") } pub fn peer_joined( @@ -345,20 +345,20 @@ pub fn session_context(room_id: &str, cwd: &str) -> Vec { pub fn turn_started( room_id: &str, - amux_turn_id: AmuxTurnId, + rooms_turn_id: RoomsTurnId, peer_id: &str, peer_name: Option<&str>, role: Option<&str>, content: &serde_json::Value, - supersedes_turn_id: Option, + supersedes_turn_id: Option, ) -> Vec { - let id = amux_turn_id.formatted(); - let supersedes = supersedes_turn_id.map(AmuxTurnId::formatted); + let id = rooms_turn_id.formatted(); + let supersedes = supersedes_turn_id.map(RoomsTurnId::formatted); encode( METHOD_TURN_STARTED, TurnStartedParams { room_id, - amux_turn_id: &id, + rooms_turn_id: &id, peer_id, peer_name, role, @@ -370,15 +370,15 @@ pub fn turn_started( pub fn turn_complete( room_id: &str, - amux_turn_id: AmuxTurnId, + rooms_turn_id: RoomsTurnId, stop_reason: &serde_json::Value, ) -> Vec { - let id = amux_turn_id.formatted(); + let id = rooms_turn_id.formatted(); encode( METHOD_TURN_COMPLETE, TurnCompleteParams { room_id, - amux_turn_id: &id, + rooms_turn_id: &id, stop_reason, }, ) @@ -397,17 +397,17 @@ pub fn session_busy(room_id: &str, busy: bool, held_by: Option<&str>) -> Vec pub fn turn_cancelled( room_id: &str, - amux_turn_id: AmuxTurnId, + rooms_turn_id: RoomsTurnId, cancelled_by: &str, original_driver: &str, reason: Option<&str>, ) -> Vec { - let id = amux_turn_id.formatted(); + let id = rooms_turn_id.formatted(); encode( METHOD_TURN_CANCELLED, TurnCancelledParams { room_id, - amux_turn_id: &id, + rooms_turn_id: &id, cancelled_by, original_driver, reason, @@ -416,7 +416,7 @@ pub fn turn_cancelled( } pub fn control_submitted(event: ControlSubmitted<'_>) -> Vec { - let id = event.amux_turn_id.map(AmuxTurnId::formatted); + let id = event.rooms_turn_id.map(RoomsTurnId::formatted); encode( METHOD_CONTROL_SUBMITTED, ControlSubmittedParams { @@ -426,7 +426,7 @@ pub fn control_submitted(event: ControlSubmitted<'_>) -> Vec { peer_id: event.peer_id, peer_name: event.peer_name, role: event.role, - amux_turn_id: id.as_deref(), + rooms_turn_id: id.as_deref(), text: event.text, }, ) @@ -457,15 +457,15 @@ pub fn queue_item_added( pub fn queue_item_submitted( room_id: &str, queue_item_id: &str, - amux_turn_id: AmuxTurnId, + rooms_turn_id: RoomsTurnId, ) -> Vec { - let id = amux_turn_id.formatted(); + let id = rooms_turn_id.formatted(); encode( METHOD_QUEUE_ITEM_SUBMITTED, QueueItemSubmittedParams { room_id, queue_item_id, - amux_turn_id: &id, + rooms_turn_id: &id, }, ) } @@ -473,16 +473,16 @@ pub fn queue_item_submitted( pub fn queue_item_completed( room_id: &str, queue_item_id: &str, - amux_turn_id: AmuxTurnId, + rooms_turn_id: RoomsTurnId, stop_reason: &serde_json::Value, ) -> Vec { - let id = amux_turn_id.formatted(); + let id = rooms_turn_id.formatted(); encode( METHOD_QUEUE_ITEM_COMPLETED, QueueItemCompletedParams { room_id, queue_item_id, - amux_turn_id: &id, + rooms_turn_id: &id, stop_reason, }, ) @@ -558,9 +558,9 @@ pub fn agent_request_opened( request_id: &serde_json::Value, request_method: &str, request_params: Option<&serde_json::Value>, - amux_turn_id: Option, + rooms_turn_id: Option, ) -> Vec { - let id = amux_turn_id.map(AmuxTurnId::formatted); + let id = rooms_turn_id.map(RoomsTurnId::formatted); encode( METHOD_AGENT_REQUEST_OPENED, AgentRequestOpenedParams { @@ -568,14 +568,14 @@ pub fn agent_request_opened( request_id, request_method, request_params, - amux_turn_id: id.as_deref(), + rooms_turn_id: id.as_deref(), }, ) } /// Lifecycle frame emitted when a segment opens. The first segment of a -/// room emits this alone (no `amux/segment_ended` precedes it). Subsequent -/// segments emit `amux/segment_ended` for the closing segment first, then +/// room emits this alone (no `rooms/segment_ended` precedes it). Subsequent +/// segments emit `rooms/segment_ended` for the closing segment first, then /// this frame for the opening one. Recorded in the transcript so late /// joiners on `historyPolicy: full_lineage` see the boundary. #[derive(Serialize)] @@ -665,8 +665,8 @@ mod tests { #[test] fn turn_id_format() { - assert_eq!(AmuxTurnId(42).formatted(), "at-42"); - assert_eq!(AmuxTurnId(0).formatted(), "at-0"); + assert_eq!(RoomsTurnId(42).formatted(), "at-42"); + assert_eq!(RoomsTurnId(0).formatted(), "at-0"); } #[test] @@ -674,7 +674,7 @@ mod tests { let bytes = peer_joined("work", "phone-1", Some("phone"), Some("default")); let v = parse(&bytes); assert_eq!(v["jsonrpc"], json!("2.0")); - assert_eq!(v["method"], json!("amux/peer_joined")); + assert_eq!(v["method"], json!("rooms/peer_joined")); assert_eq!(v["params"]["roomId"], json!("work")); assert_eq!(v["params"]["peerId"], json!("phone-1")); assert_eq!(v["params"]["peerName"], json!("phone")); @@ -692,7 +692,7 @@ mod tests { #[test] fn peer_left_shape() { let v = parse(&peer_left("work", "p1")); - assert_eq!(v["method"], json!("amux/peer_left")); + assert_eq!(v["method"], json!("rooms/peer_left")); assert_eq!(v["params"]["roomId"], json!("work")); assert_eq!(v["params"]["peerId"], json!("p1")); assert!(v.get("id").is_none()); @@ -703,15 +703,15 @@ mod tests { let content = json!([{"type": "text", "text": "hi"}]); let v = parse(&turn_started( "work", - AmuxTurnId(7), + RoomsTurnId(7), "phone-1", Some("phone"), None, &content, None, )); - assert_eq!(v["method"], json!("amux/turn_started")); - assert_eq!(v["params"]["amuxTurnId"], json!("at-7")); + assert_eq!(v["method"], json!("rooms/turn_started")); + assert_eq!(v["params"]["roomsTurnId"], json!("at-7")); assert_eq!(v["params"]["peerId"], json!("phone-1")); assert_eq!(v["params"]["peerName"], json!("phone")); assert!(v["params"].get("role").is_none()); @@ -721,16 +721,16 @@ mod tests { #[test] fn turn_complete_shape() { let reason = json!("end_turn"); - let v = parse(&turn_complete("work", AmuxTurnId(7), &reason)); - assert_eq!(v["method"], json!("amux/turn_complete")); - assert_eq!(v["params"]["amuxTurnId"], json!("at-7")); + let v = parse(&turn_complete("work", RoomsTurnId(7), &reason)); + assert_eq!(v["method"], json!("rooms/turn_complete")); + assert_eq!(v["params"]["roomsTurnId"], json!("at-7")); assert_eq!(v["params"]["stopReason"], json!("end_turn")); } #[test] fn session_busy_shape() { let v = parse(&session_busy("work", true, Some("desktop-1"))); - assert_eq!(v["method"], json!("amux/session_busy")); + assert_eq!(v["method"], json!("rooms/session_busy")); assert_eq!(v["params"]["roomId"], json!("work")); assert_eq!(v["params"]["busy"], json!(true)); assert_eq!(v["params"]["heldBy"], json!("desktop-1")); @@ -751,9 +751,9 @@ mod tests { &req_id, "session/request_permission", Some(¶ms), - Some(AmuxTurnId(7)), + Some(RoomsTurnId(7)), )); - assert_eq!(v["method"], json!("amux/agent_request_opened")); + assert_eq!(v["method"], json!("rooms/agent_request_opened")); assert!(v.get("id").is_none()); assert_eq!(v["params"]["roomId"], json!("work")); assert_eq!(v["params"]["requestId"], req_id); @@ -762,7 +762,7 @@ mod tests { json!("session/request_permission") ); assert_eq!(v["params"]["requestParams"], params); - assert_eq!(v["params"]["amuxTurnId"], json!("at-7")); + assert_eq!(v["params"]["roomsTurnId"], json!("at-7")); } #[test] @@ -777,7 +777,7 @@ mod tests { )); assert_eq!(v["params"]["requestId"], req_id); assert!(v["params"].get("requestParams").is_none()); - assert!(v["params"].get("amuxTurnId").is_none()); + assert!(v["params"].get("roomsTurnId").is_none()); } #[test] @@ -791,7 +791,7 @@ mod tests { Some(&result), None, )); - assert_eq!(v["method"], json!("amux/agent_request_resolved")); + assert_eq!(v["method"], json!("rooms/agent_request_resolved")); assert_eq!(v["params"]["roomId"], json!("work")); assert_eq!(v["params"]["requestId"], req_id); assert_eq!(v["params"]["resolvedBy"], json!("alice")); diff --git a/crates/rooms/src/room/mod.rs b/crates/rooms/src/room/mod.rs new file mode 100644 index 0000000..812c56c --- /dev/null +++ b/crates/rooms/src/room/mod.rs @@ -0,0 +1,2 @@ +pub mod registry; +pub use acp_mux::replay_store; diff --git a/crates/rooms/src/room/registry.rs b/crates/rooms/src/room/registry.rs new file mode 100644 index 0000000..d29d14d --- /dev/null +++ b/crates/rooms/src/room/registry.rs @@ -0,0 +1,179 @@ +//! Rooms room registry wrapper over the core mux registry. + +use std::sync::Arc; +use std::time::Duration; + +pub use acp_mux::mux::{AgentCmd, ControlPlaneSessionListError, RegistryError}; +use acp_mux::mux::{MuxHandle, MuxRegistry, MuxSnapshot}; +use acp_mux::subscriber::Subscriber; +use serde_json::Value; + +use crate::cli::{ClientToolPolicy, ReplayTurns}; +use crate::extension::{RoomsExtension, RoomsOptions, SessionListMetadataIndex}; +use crate::room::replay_store::ReplayStore; + +pub struct RoomRegistry { + inner: Arc, + #[allow(dead_code)] + session_list_index: Arc, +} + +impl RoomRegistry { + pub fn new( + agent_cmd: Option, + replay_policy: ReplayTurns, + session_ttl: Duration, + ) -> Arc { + Self::new_with_client_tool_policy( + agent_cmd, + replay_policy, + session_ttl, + false, + ClientToolPolicy::default(), + ) + } + + pub fn new_with_meta_propagation( + agent_cmd: Option, + replay_policy: ReplayTurns, + session_ttl: Duration, + meta_propagate: bool, + ) -> Arc { + Self::new_with_client_tool_policy( + agent_cmd, + replay_policy, + session_ttl, + meta_propagate, + ClientToolPolicy::default(), + ) + } + + pub fn new_with_client_tool_policy( + agent_cmd: Option, + replay_policy: ReplayTurns, + session_ttl: Duration, + meta_propagate: bool, + client_tool_policy: ClientToolPolicy, + ) -> Arc { + Self::new_with_options( + agent_cmd, + replay_policy, + session_ttl, + meta_propagate, + client_tool_policy, + true, + ) + } + + pub fn new_with_options( + agent_cmd: Option, + replay_policy: ReplayTurns, + session_ttl: Duration, + meta_propagate: bool, + client_tool_policy: ClientToolPolicy, + emit_segment_frames: bool, + ) -> Arc { + Self::new_with_replay_store( + agent_cmd, + replay_policy, + session_ttl, + meta_propagate, + client_tool_policy, + emit_segment_frames, + None, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_with_replay_store( + agent_cmd: Option, + replay_policy: ReplayTurns, + session_ttl: Duration, + meta_propagate: bool, + client_tool_policy: ClientToolPolicy, + emit_segment_frames: bool, + replay_store: Option>, + ) -> Arc { + Self::new_full( + agent_cmd, + replay_policy, + session_ttl, + meta_propagate, + client_tool_policy, + emit_segment_frames, + replay_store, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_full( + agent_cmd: Option, + replay_policy: ReplayTurns, + session_ttl: Duration, + meta_propagate: bool, + client_tool_policy: ClientToolPolicy, + emit_segment_frames: bool, + replay_store: Option>, + ) -> Arc { + let session_list_index = Arc::new(SessionListMetadataIndex::new()); + let extension_index = session_list_index.clone(); + let inner = MuxRegistry::with_extension_and_replay_store( + agent_cmd, + replay_policy, + session_ttl, + client_tool_policy, + replay_store, + move || { + Box::new(RoomsExtension::new( + RoomsOptions { + meta_propagate, + emit_segment_frames, + }, + extension_index.clone(), + )) + }, + ); + Arc::new(Self { + inner, + session_list_index, + }) + } + + pub async fn list_sessions_control_plane( + &self, + cwd: Option, + ) -> Result { + self.inner.list_sessions_control_plane(cwd).await + } + + pub async fn attach( + self: &Arc, + room_id: &str, + subscriber: Subscriber, + ) -> Result { + self.inner.attach(room_id, subscriber).await + } + + pub async fn shutdown(&self) { + self.inner.shutdown().await; + } + + pub async fn snapshot(&self) -> Vec { + self.inner.snapshot().await + } + + pub async fn live_session_count(&self) -> usize { + self.inner.live_mux_count().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn control_plane_agent_timeout_allows_slow_agent_startup() { + // Kept as a stable smoke test for the public registry module. + assert!(Duration::from_secs(8) >= Duration::from_secs(8)); + } +} diff --git a/src/server.rs b/crates/rooms/src/server.rs similarity index 90% rename from src/server.rs rename to crates/rooms/src/server.rs index 2a46dbd..8041a56 100644 --- a/src/server.rs +++ b/crates/rooms/src/server.rs @@ -39,7 +39,7 @@ use tokio::sync::mpsc; use crate::multiplex::subscriber::{OutMsg, Subscriber}; use crate::room::registry::{ControlPlaneSessionListError, RegistryError, RoomRegistry}; -use crate::room::state::{RoomMsg, RoomSnapshot}; +use acp_mux::mux::MuxMsg; const ROOM_ID_MAX_LEN: usize = 128; @@ -94,12 +94,19 @@ async fn healthz() -> &'static str { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct DebugSessionsResponse { - rooms: Vec, + rooms: Vec, room_count: usize, } async fn debug_sessions(State(state): State) -> impl IntoResponse { - let rooms = state.registry.snapshot().await; + let rooms: Vec<_> = state + .registry + .snapshot() + .await + .into_iter() + .filter_map(|snapshot| serde_json::to_value(snapshot).ok()) + .map(legacy_debug_room) + .collect(); let count = rooms.len(); Json(DebugSessionsResponse { rooms, @@ -107,6 +114,37 @@ async fn debug_sessions(State(state): State) -> impl IntoResponse { }) } +fn legacy_debug_room(mut room: serde_json::Value) -> serde_json::Value { + let Some(obj) = room.as_object_mut() else { + return room; + }; + if let Some(mux_id) = obj.get("muxId").cloned() { + obj.insert("roomId".to_string(), mux_id); + } + if let Some(prompt_in_flight) = obj.get("promptInFlight").cloned() { + obj.insert("activeTurnMuxId".to_string(), prompt_in_flight); + } + let driving = obj + .get("drivingSubscriber") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + if let Some(subscribers) = obj + .get_mut("subscribers") + .and_then(serde_json::Value::as_array_mut) + { + for subscriber in subscribers { + let is_driving = subscriber + .get("peerId") + .and_then(serde_json::Value::as_str) + .is_some_and(|peer_id| driving.as_deref() == Some(peer_id)); + if let Some(subscriber) = subscriber.as_object_mut() { + subscriber.insert("isDriving".to_string(), serde_json::json!(is_driving)); + } + } + } + room +} + #[derive(Serialize)] struct ErrorResponse { error: &'static str, @@ -226,7 +264,7 @@ async fn handle_attach(state: AppState, q: AttachQuery, mut socket: WebSocket) { // Send may fail if the room actor already exited; that's fine. let _ = handle .tx - .send(RoomMsg::Detach { + .send(MuxMsg::Detach { peer_id: peer_id.clone(), }) .await; @@ -236,7 +274,7 @@ async fn handle_attach(state: AppState, q: AttachQuery, mut socket: WebSocket) { async fn ws_in_task( mut ws_stream: SplitStream, peer_id: String, - room_tx: mpsc::Sender, + room_tx: mpsc::Sender, room: String, ) { while let Some(msg) = ws_stream.next().await { @@ -244,7 +282,7 @@ async fn ws_in_task( Ok(Message::Text(t)) => { let bytes = strip_trailing_newline(t.as_bytes()); if room_tx - .send(RoomMsg::InboundFromSubscriber { + .send(MuxMsg::InboundFromSubscriber { peer_id: peer_id.clone(), bytes, }) @@ -258,7 +296,7 @@ async fn ws_in_task( Ok(Message::Binary(b)) => { let bytes = strip_trailing_newline(&b); if room_tx - .send(RoomMsg::InboundFromSubscriber { + .send(MuxMsg::InboundFromSubscriber { peer_id: peer_id.clone(), bytes, }) diff --git a/tests/server.rs b/crates/rooms/tests/server.rs similarity index 87% rename from tests/server.rs rename to crates/rooms/tests/server.rs index 6de78ed..e0ca09d 100644 --- a/tests/server.rs +++ b/crates/rooms/tests/server.rs @@ -11,13 +11,13 @@ use std::sync::Arc; -use amux::cli::{ClientToolPolicy, ReplayTurns}; -use amux::room::registry::{AgentCmd, RoomRegistry}; -use amux::room::replay_store::ReplayStore; -use amux::server::{ +use futures::{SinkExt, StreamExt}; +use rooms::cli::{ClientToolPolicy, ReplayTurns}; +use rooms::room::registry::{AgentCmd, RoomRegistry}; +use rooms::room::replay_store::ReplayStore; +use rooms::server::{ AppState, CLOSE_CODE_BAD_QUERY, CLOSE_CODE_INTERNAL, CLOSE_CODE_PEER_CONFLICT, router, }; -use futures::{SinkExt, StreamExt}; use std::net::SocketAddr; use std::time::Duration; use tokio::time::timeout; @@ -164,28 +164,28 @@ async fn ws_loopback_roundtrip_via_cat() { if t.as_str() == payload { assert!( saw_opened, - "agent request echoes should be preceded by inert amux/agent_request_opened metadata" + "agent request echoes should be preceded by inert rooms/agent_request_opened metadata" ); saw_echo = true; break; } let v: serde_json::Value = serde_json::from_str(t.as_str()).expect("frame is JSON"); - if v.get("method") == Some(&serde_json::json!("amux/session_context")) { + if v.get("method") == Some(&serde_json::json!("rooms/session_context")) { continue; } - if v.get("method") == Some(&serde_json::json!("amux/agent_request_opened")) { + if v.get("method") == Some(&serde_json::json!("rooms/agent_request_opened")) { saw_opened = true; continue; } - panic!("expected text echo or amux/agent_request_opened, got {v:?}"); + panic!("expected text echo or rooms/agent_request_opened, got {v:?}"); } assert!( saw_opened, - "expected amux/agent_request_opened before raw echo" + "expected rooms/agent_request_opened before raw echo" ); assert!( saw_echo, - "expected raw text echo after amux/agent_request_opened" + "expected raw text echo after rooms/agent_request_opened" ); ws.send(ClientMsg::Close(None)).await.unwrap(); @@ -213,7 +213,7 @@ async fn subscriber_receives_agent_context_cwd_on_attach() { .await .expect("ws connect"); - let context = ws_next_method(&mut ws, "amux/session_context").await; + let context = ws_next_method(&mut ws, "rooms/session_context").await; assert_eq!(context["params"]["roomId"], serde_json::json!("ctx")); assert_eq!(context["params"]["cwd"], serde_json::json!(expected_cwd)); @@ -232,7 +232,7 @@ async fn ws_accepts_deprecated_session_alias() { .await .expect("ws connect with deprecated alias"); - let context = ws_next_method(&mut ws, "amux/session_context").await; + let context = ws_next_method(&mut ws, "rooms/session_context").await; assert_eq!( context["params"]["roomId"], serde_json::json!("legacy"), @@ -273,7 +273,7 @@ async fn ws_two_subscribers_see_naive_fanout() { ws_a.send(ClientMsg::Text(payload.into())).await.unwrap(); // Both subscribers should see the echoed `session/update` line. - // amux/peer_joined frames may also be in the queue (A receives it + // rooms/peer_joined frames may also be in the queue (A receives it // when B joined); skip until we see the expected method. for ws in [&mut ws_a, &mut ws_b] { let mut found = false; @@ -336,7 +336,7 @@ async fn ws_request( }; let v: serde_json::Value = serde_json::from_str(t.as_str()).expect("frame is JSON"); // Skip notifications (no id) and any frame carrying a `method` - // — that's amux/* metadata or agent session/update broadcasts. + // — that's rooms/* metadata or agent session/update broadcasts. if v.get("method").is_some() { continue; } @@ -379,7 +379,7 @@ async fn rfd533_attach_returns_roster_and_history_policy() { init["result"]["agentCapabilities"]["sessionCapabilities"] .get("attach") .is_none(), - "amux should not inject attach capability into upstream initialize responses: {init:?}", + "rooms should not inject attach capability into upstream initialize responses: {init:?}", ); let _ = ws_request( &mut ws, @@ -408,21 +408,21 @@ async fn rfd533_attach_returns_roster_and_history_policy() { ); assert!( none["result"].get("connectedClients").is_none(), - "amux-specific roster metadata should not sit at the top level: {none:?}", + "rooms-specific roster metadata should not sit at the top level: {none:?}", ); assert!( - none["result"]["_meta"]["amux"]["connectedClients"] + none["result"]["_meta"]["rooms"]["connectedClients"] .as_array() .unwrap() .iter() .any(|c| c["clientId"] == serde_json::json!("A") && c["name"] == serde_json::json!("Alice")), - "attach result should expose current peer roster under _meta.amux: {none:?}", + "attach result should expose current peer roster under _meta.rooms: {none:?}", ); assert_eq!( - none["result"]["_meta"]["amux"]["appliedReplayOrder"], + none["result"]["_meta"]["rooms"]["appliedReplayOrder"], serde_json::json!("chronological"), - "attach should echo the effective amux replay order in extension metadata: {none:?}", + "attach should echo the effective rooms replay order in extension metadata: {none:?}", ); let after_message = ws_request( @@ -470,16 +470,16 @@ async fn rfd533_attach_full_history_can_be_returned_newest_turn_first_without_re let attach = ws_request( &mut ws, - r#"{"jsonrpc":"2.0","id":5,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"full","_meta":{"amux":{"replayOrder":"newest_turn_first"}}}}"#, + r#"{"jsonrpc":"2.0","id":5,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"full","_meta":{"rooms":{"replayOrder":"newest_turn_first"}}}}"#, ) .await; assert_eq!(attach["result"]["historyPolicy"], serde_json::json!("full")); assert_eq!( - attach["result"]["_meta"]["amux"]["appliedReplayOrder"], + attach["result"]["_meta"]["rooms"]["appliedReplayOrder"], serde_json::json!("newest_turn_first") ); assert_eq!( - attach["result"]["_meta"]["amux"]["appliedHistoryDelivery"], + attach["result"]["_meta"]["rooms"]["appliedHistoryDelivery"], serde_json::json!("response"), "newest_turn_first without historyDelivery=stream must keep Phase 1 response-body semantics" ); @@ -488,7 +488,7 @@ async fn rfd533_attach_full_history_can_be_returned_newest_turn_first_without_re history.iter().all(|entry| { !matches!( entry["method"].as_str(), - Some("amux/session_snapshot" | "amux/replay_started" | "amux/replay_complete") + Some("rooms/session_snapshot" | "rooms/replay_started" | "rooms/replay_complete") ) }), "attach response history should not use streamed replay marker frames: {attach:?}", @@ -497,7 +497,7 @@ async fn rfd533_attach_full_history_can_be_returned_newest_turn_first_without_re let turn_starts: Vec<(usize, String)> = history .iter() .enumerate() - .filter(|(_, entry)| entry["method"] == serde_json::json!("amux/turn_started")) + .filter(|(_, entry)| entry["method"] == serde_json::json!("rooms/turn_started")) .map(|(idx, entry)| { ( idx, @@ -523,10 +523,10 @@ async fn rfd533_attach_full_history_can_be_returned_newest_turn_first_without_re assert_eq!( segment_methods, vec![ - "amux/turn_started", + "rooms/turn_started", "session/update", "session/update", - "amux/turn_complete", + "rooms/turn_complete", ], "within a newest-first turn segment, frames must remain chronological: {history:?}", ); @@ -570,7 +570,7 @@ async fn rfd533_attach_streams_latest_segment_then_backfills_older_turns() { .await; let attach = ws_request( &mut ws_b, - r#"{"jsonrpc":"2.0","id":11,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"full","clientId":"client-B","_meta":{"amux":{"replayOrder":"newest_turn_first","historyDelivery":"stream"}}}}"#, + r#"{"jsonrpc":"2.0","id":11,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"full","clientId":"client-B","_meta":{"rooms":{"replayOrder":"newest_turn_first","historyDelivery":"stream"}}}}"#, ) .await; assert_eq!(attach["result"]["historyPolicy"], serde_json::json!("full")); @@ -579,14 +579,14 @@ async fn rfd533_attach_streams_latest_segment_then_backfills_older_turns() { "streaming attach keeps the JSON-RPC response bounded and moves history to post-response notifications: {attach:?}", ); assert_eq!( - attach["result"]["_meta"]["amux"]["appliedReplayOrder"], + attach["result"]["_meta"]["rooms"]["appliedReplayOrder"], serde_json::json!("newest_turn_first") ); assert_eq!( - attach["result"]["_meta"]["amux"]["appliedHistoryDelivery"], + attach["result"]["_meta"]["rooms"]["appliedHistoryDelivery"], serde_json::json!("stream") ); - let snapshot = &attach["result"]["_meta"]["amux"]["snapshot"]; + let snapshot = &attach["result"]["_meta"]["rooms"]["snapshot"]; assert!( snapshot["replayBoundarySeq"] .as_u64() @@ -602,7 +602,7 @@ async fn rfd533_attach_streams_latest_segment_then_backfills_older_turns() { "snapshot should include the current attached peer roster: {attach:?}", ); - let latest_started = ws_next_method(&mut ws_b, "amux/replay_started").await; + let latest_started = ws_next_method(&mut ws_b, "rooms/replay_started").await; assert_eq!( latest_started["params"]["phase"], serde_json::json!("latest_segment") @@ -612,41 +612,41 @@ async fn rfd533_attach_streams_latest_segment_then_backfills_older_turns() { serde_json::json!("newest_turn_first") ); - let latest_turn = ws_next_method(&mut ws_b, "amux/turn_started").await; + let latest_turn = ws_next_method(&mut ws_b, "rooms/turn_started").await; assert_eq!( latest_turn["params"]["content"][0]["text"], serde_json::json!("third turn"), "latest segment should be the newest completed turn, not the oldest backfill" ); - let latest_complete_turn = ws_next_method(&mut ws_b, "amux/turn_complete").await; + let latest_complete_turn = ws_next_method(&mut ws_b, "rooms/turn_complete").await; assert_eq!( - latest_complete_turn["params"]["amuxTurnId"], - latest_turn["params"]["amuxTurnId"] + latest_complete_turn["params"]["roomsTurnId"], + latest_turn["params"]["roomsTurnId"] ); - let latest_complete = ws_next_method(&mut ws_b, "amux/replay_complete").await; + let latest_complete = ws_next_method(&mut ws_b, "rooms/replay_complete").await; assert_eq!( latest_complete["params"]["phase"], serde_json::json!("latest_segment") ); - let backfill_started = ws_next_method(&mut ws_b, "amux/replay_started").await; + let backfill_started = ws_next_method(&mut ws_b, "rooms/replay_started").await; assert_eq!( backfill_started["params"]["phase"], serde_json::json!("backfill") ); - let second_turn = ws_next_method(&mut ws_b, "amux/turn_started").await; + let second_turn = ws_next_method(&mut ws_b, "rooms/turn_started").await; assert_eq!( second_turn["params"]["content"][0]["text"], serde_json::json!("second turn"), "backfill should proceed newest older turn first" ); - let first_turn = ws_next_method(&mut ws_b, "amux/turn_started").await; + let first_turn = ws_next_method(&mut ws_b, "rooms/turn_started").await; assert_eq!( first_turn["params"]["content"][0]["text"], serde_json::json!("first turn"), "backfilled turn frames should stay chronological within each newest-to-oldest segment" ); - let backfill_complete = ws_next_method(&mut ws_b, "amux/replay_complete").await; + let backfill_complete = ws_next_method(&mut ws_b, "rooms/replay_complete").await; assert_eq!( backfill_complete["params"]["phase"], serde_json::json!("backfill") @@ -691,19 +691,19 @@ async fn rfd533_attach_stream_chronological_backfills_in_original_order() { .await; let attach = ws_request( &mut ws_b, - r#"{"jsonrpc":"2.0","id":11,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"full","_meta":{"amux":{"historyDelivery":"stream"}}}}"#, + r#"{"jsonrpc":"2.0","id":11,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"full","_meta":{"rooms":{"historyDelivery":"stream"}}}}"#, ) .await; assert_eq!( - attach["result"]["_meta"]["amux"]["appliedReplayOrder"], + attach["result"]["_meta"]["rooms"]["appliedReplayOrder"], serde_json::json!("chronological") ); assert_eq!( - attach["result"]["_meta"]["amux"]["appliedHistoryDelivery"], + attach["result"]["_meta"]["rooms"]["appliedHistoryDelivery"], serde_json::json!("stream") ); - let backfill_started = ws_next_method(&mut ws_b, "amux/replay_started").await; + let backfill_started = ws_next_method(&mut ws_b, "rooms/replay_started").await; assert_eq!( backfill_started["params"]["phase"], serde_json::json!("backfill"), @@ -713,8 +713,8 @@ async fn rfd533_attach_stream_chronological_backfills_in_original_order() { backfill_started["params"]["replayOrder"], serde_json::json!("chronological") ); - let first = ws_next_method(&mut ws_b, "amux/turn_started").await; - let second = ws_next_method(&mut ws_b, "amux/turn_started").await; + let first = ws_next_method(&mut ws_b, "rooms/turn_started").await; + let second = ws_next_method(&mut ws_b, "rooms/turn_started").await; assert_eq!( first["params"]["content"][0]["text"], serde_json::json!("first chrono turn") @@ -764,15 +764,15 @@ async fn rfd533_streaming_attach_does_not_block_live_events_behind_backfill() { .await; let attach = ws_request( &mut ws_b, - r#"{"jsonrpc":"2.0","id":21,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"full","_meta":{"amux":{"replayOrder":"newest_turn_first","historyDelivery":"stream"}}}}"#, + r#"{"jsonrpc":"2.0","id":21,"method":"session/attach","params":{"sessionId":"sess-mock","historyPolicy":"full","_meta":{"rooms":{"replayOrder":"newest_turn_first","historyDelivery":"stream"}}}}"#, ) .await; assert_eq!( - attach["result"]["_meta"]["amux"]["appliedHistoryDelivery"], + attach["result"]["_meta"]["rooms"]["appliedHistoryDelivery"], serde_json::json!("stream") ); - let latest_complete = ws_next_method(&mut ws_b, "amux/replay_complete").await; + let latest_complete = ws_next_method(&mut ws_b, "rooms/replay_complete").await; assert_eq!( latest_complete["params"]["phase"], serde_json::json!("latest_segment") @@ -795,14 +795,14 @@ async fn rfd533_streaming_attach_does_not_block_live_events_behind_backfill() { continue; }; let v: serde_json::Value = serde_json::from_str(t.as_str()).expect("frame is JSON"); - if v.get("method") == Some(&serde_json::json!("amux/replay_complete")) + if v.get("method") == Some(&serde_json::json!("rooms/replay_complete")) && v["params"]["phase"] == serde_json::json!("backfill") { panic!( "live event with seq > snapshot boundary waited behind all backfill frames: {v:?}" ); } - if v.get("method") == Some(&serde_json::json!("amux/turn_started")) + if v.get("method") == Some(&serde_json::json!("rooms/turn_started")) && v["params"]["content"][0]["text"] == serde_json::json!("live while backfill") { let _ = ws_a.send(ClientMsg::Close(None)).await; @@ -814,7 +814,7 @@ async fn rfd533_streaming_attach_does_not_block_live_events_behind_backfill() { } #[tokio::test] -async fn rfd533_attach_pending_only_reissues_permission_and_keeps_resolution_in_amux() { +async fn rfd533_attach_pending_only_reissues_permission_and_keeps_resolution_in_rooms() { let (addr, _) = spawn_server_with_mock_env(&[ ("MOCK_ACP_EMIT_PERMISSION", "1"), ("MOCK_ACP_PROMPT_DELAY_MS", "2000"), @@ -885,7 +885,7 @@ async fn rfd533_attach_pending_only_reissues_permission_and_keeps_resolution_in_ .await .unwrap(); - let resolved = ws_next_method(&mut ws_a, "amux/agent_request_resolved").await; + let resolved = ws_next_method(&mut ws_a, "rooms/agent_request_resolved").await; assert_eq!(resolved["params"]["roomId"], serde_json::json!("rfd533")); assert_eq!(resolved["params"]["requestId"], permission_a["id"]); assert_eq!(resolved["params"]["resolvedBy"], serde_json::json!("B")); @@ -899,7 +899,7 @@ async fn rfd533_attach_pending_only_reissues_permission_and_keeps_resolution_in_ v.get("method") != Some(&serde_json::json!("session/update")) || v["params"]["update"].get("type").is_none() }), - "permission resolution should stay in amux/*, not fabricated session/update siblings: {followup:?}", + "permission resolution should stay in rooms/*, not fabricated session/update siblings: {followup:?}", ); let _ = ws_a.send(ClientMsg::Close(None)).await; @@ -907,7 +907,7 @@ async fn rfd533_attach_pending_only_reissues_permission_and_keeps_resolution_in_ } #[tokio::test] -async fn rfd533_attach_detach_keeps_lifecycle_in_amux_namespace() { +async fn rfd533_attach_detach_keeps_lifecycle_in_rooms_namespace() { let (addr, _) = spawn_server_with_mock().await; let url_a = format!("ws://{addr}/acp?room=rfd533&peer_id=A&peer_name=Alice"); let url_b = format!("ws://{addr}/acp?room=rfd533&peer_id=B&peer_name=Bob"); @@ -941,14 +941,14 @@ async fn rfd533_attach_detach_keeps_lifecycle_in_amux_namespace() { .await .unwrap(); - let turn_started = ws_next_method(&mut ws_b, "amux/turn_started").await; + let turn_started = ws_next_method(&mut ws_b, "rooms/turn_started").await; assert_eq!(turn_started["params"]["peerId"], serde_json::json!("A")); assert_eq!( turn_started["params"]["content"][0]["text"], serde_json::json!("hello from A") ); - let turn_complete = ws_next_method(&mut ws_b, "amux/turn_complete").await; + let turn_complete = ws_next_method(&mut ws_b, "rooms/turn_complete").await; assert_eq!( turn_complete["params"]["stopReason"], serde_json::json!("end_turn") @@ -965,7 +965,7 @@ async fn rfd533_attach_detach_keeps_lifecycle_in_amux_namespace() { serde_json::json!("sess-mock") ); - let disconnected = ws_next_method(&mut ws_a, "amux/peer_left").await; + let disconnected = ws_next_method(&mut ws_a, "rooms/peer_left").await; assert_eq!(disconnected["params"]["peerId"], serde_json::json!("B")); let followup = drain_for(&mut ws_a, Duration::from_millis(100)).await; assert!( @@ -979,7 +979,7 @@ async fn rfd533_attach_detach_keeps_lifecycle_in_amux_namespace() { let _ = ws_a.send(ClientMsg::Close(None)).await; } -// ===== _meta.amux request trace propagation (issue #6) ===== +// ===== _meta.rooms request trace propagation (issue #6) ===== #[tokio::test] async fn meta_propagate_default_off_leaves_outbound_request_meta_unchanged() { @@ -1000,8 +1000,8 @@ async fn meta_propagate_default_off_leaves_outbound_request_meta_unchanged() { serde_json::json!("00-abc") ); assert!( - echoed["params"]["_meta"].get("amux").is_none(), - "default-off meta propagation must not add _meta.amux: {echoed:?}", + echoed["params"]["_meta"].get("rooms").is_none(), + "default-off meta propagation must not add _meta.rooms: {echoed:?}", ); let _ = ws.send(ClientMsg::Close(None)).await; @@ -1021,7 +1021,7 @@ async fn meta_propagate_opt_in_adds_peer_trace_to_outbound_requests() { let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap(); ws.send(ClientMsg::Text( - r#"{"jsonrpc":"2.0","id":77,"method":"session/list","params":{"cwd":"/tmp","_meta":{"traceparent":"00-abc","client.example/debug":true,"amux":{"clientTrace":"keep"}}}}"#.into(), + r#"{"jsonrpc":"2.0","id":77,"method":"session/list","params":{"cwd":"/tmp","_meta":{"traceparent":"00-abc","client.example/debug":true,"rooms":{"clientTrace":"keep"}}}}"#.into(), )) .await .unwrap(); @@ -1035,17 +1035,17 @@ async fn meta_propagate_opt_in_adds_peer_trace_to_outbound_requests() { assert_eq!( echoed["params"]["_meta"]["client.example/debug"], serde_json::json!(true), - "existing non-amux metadata must be preserved", + "existing non-rooms metadata must be preserved", ); - let amux = &echoed["params"]["_meta"]["amux"]; - assert_eq!(amux["peerId"], serde_json::json!("A")); - assert_eq!(amux["peerName"], serde_json::json!("Alice")); - assert_eq!(amux["role"], serde_json::json!("driver")); - assert_eq!(amux["muxId"], serde_json::json!(1)); - assert_eq!(amux["clientTrace"], serde_json::json!("keep")); + let rooms = &echoed["params"]["_meta"]["rooms"]; + assert_eq!(rooms["peerId"], serde_json::json!("A")); + assert_eq!(rooms["peerName"], serde_json::json!("Alice")); + assert_eq!(rooms["role"], serde_json::json!("driver")); + assert_eq!(rooms["muxId"], serde_json::json!(1)); + assert_eq!(rooms["clientTrace"], serde_json::json!("keep")); assert!( - amux.get("amuxTurnId").is_none(), + rooms.get("roomsTurnId").is_none(), "non-prompt requests should not carry a turn id: {echoed:?}", ); @@ -1053,7 +1053,7 @@ async fn meta_propagate_opt_in_adds_peer_trace_to_outbound_requests() { } #[tokio::test] -async fn meta_propagate_prompt_includes_amux_turn_id() { +async fn meta_propagate_prompt_includes_rooms_turn_id() { let (addr, _) = spawn_server_with_meta_propagation( Some(AgentCmd { program: "cat".into(), @@ -1072,10 +1072,10 @@ async fn meta_propagate_prompt_includes_amux_turn_id() { .unwrap(); let echoed = ws_next_method(&mut ws, "session/prompt").await; - let amux = &echoed["params"]["_meta"]["amux"]; - assert_eq!(amux["peerId"], serde_json::json!("A")); - assert_eq!(amux["muxId"], serde_json::json!(1)); - assert_eq!(amux["amuxTurnId"], serde_json::json!("at-1")); + let rooms = &echoed["params"]["_meta"]["rooms"]; + assert_eq!(rooms["peerId"], serde_json::json!("A")); + assert_eq!(rooms["muxId"], serde_json::json!(1)); + assert_eq!(rooms["roomsTurnId"], serde_json::json!("at-1")); let _ = ws.send(ClientMsg::Close(None)).await; } @@ -1230,7 +1230,7 @@ async fn prompt_notifications_broadcast_response_routes_to_originator() { } // Both A and B should have seen only the two agent-emitted chunks; - // mux lifecycle remains in amux/*. + // mux lifecycle remains in rooms/*. let count_updates = |frames: &[serde_json::Value]| { frames .iter() @@ -1329,7 +1329,7 @@ fn mock_agent_cmd_with_env(env: &[(&str, &str)]) -> AgentCmd { } /// Drain all text frames from `ws` until `dur` elapses; returns them -/// as parsed JSON values. Used to collect amux/* notification streams +/// as parsed JSON values. Used to collect rooms/* notification streams /// without locking the test to a specific arrival order. async fn drain_for( ws: &mut tokio_tungstenite::WebSocketStream, @@ -1353,11 +1353,11 @@ where out } -/// Chunk 7: amux/peer_joined fires when B joins, A sees it; B does not +/// Chunk 7: rooms/peer_joined fires when B joins, A sees it; B does not /// see their own join (emit-before-insert). On detach the remaining -/// subscriber sees amux/peer_left. +/// subscriber sees rooms/peer_left. #[tokio::test] -async fn amux_peer_joined_and_peer_left() { +async fn rooms_peer_joined_and_peer_left() { let (addr, _) = spawn_server_with_mock().await; let url_a = format!("ws://{addr}/acp?room=presence&peer_id=A&peer_name=Alice"); let url_b = format!("ws://{addr}/acp?room=presence&peer_id=B&peer_name=Bob"); @@ -1369,13 +1369,13 @@ async fn amux_peer_joined_and_peer_left() { assert!( a_early .iter() - .any(|v| v.get("method") == Some(&serde_json::json!("amux/session_context"))), + .any(|v| v.get("method") == Some(&serde_json::json!("rooms/session_context"))), "A should receive direct session_context on attach, got {a_early:?}" ); assert!( a_early .iter() - .all(|v| v.get("method") == Some(&serde_json::json!("amux/session_context"))), + .all(|v| v.get("method") == Some(&serde_json::json!("rooms/session_context"))), "A should see no peer/presence events before B joins, got {a_early:?}" ); @@ -1384,8 +1384,8 @@ async fn amux_peer_joined_and_peer_left() { let a_after_b = drain_for(&mut ws_a, Duration::from_millis(150)).await; let pj = a_after_b .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/peer_joined"))) - .expect("A should see amux/peer_joined for B"); + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/peer_joined"))) + .expect("A should see rooms/peer_joined for B"); assert_eq!(pj["params"]["peerId"], serde_json::json!("B")); assert_eq!(pj["params"]["peerName"], serde_json::json!("Bob")); assert_eq!(pj["params"]["roomId"], serde_json::json!("presence")); @@ -1395,11 +1395,11 @@ async fn amux_peer_joined_and_peer_left() { // join is appended to the log AFTER the snapshot is taken). let b_early = drain_for(&mut ws_b, Duration::from_millis(150)).await; let saw_a_join = b_early.iter().any(|v| { - v.get("method") == Some(&serde_json::json!("amux/peer_joined")) + v.get("method") == Some(&serde_json::json!("rooms/peer_joined")) && v["params"]["peerId"] == serde_json::json!("A") }); let saw_own_join = b_early.iter().any(|v| { - v.get("method") == Some(&serde_json::json!("amux/peer_joined")) + v.get("method") == Some(&serde_json::json!("rooms/peer_joined")) && v["params"]["peerId"] == serde_json::json!("B") }); assert!( @@ -1417,8 +1417,8 @@ async fn amux_peer_joined_and_peer_left() { let a_after_detach = drain_for(&mut ws_a, Duration::from_millis(200)).await; let pl = a_after_detach .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/peer_left"))) - .expect("A should see amux/peer_left for B"); + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/peer_left"))) + .expect("A should see rooms/peer_left for B"); assert_eq!(pl["params"]["peerId"], serde_json::json!("B")); let _ = ws_a.send(ClientMsg::Close(None)).await; @@ -1616,7 +1616,7 @@ async fn replay_log_delivers_history_to_late_joiner() { // The replay should include, in this order: // peer_joined(A), turn_started(A), 2x agent session/update, // turn_complete. The RFD #533 attach/detach foundation deliberately - // keeps lifecycle notifications in amux/* rather than fabricating + // keeps lifecycle notifications in rooms/* rather than fabricating // proxy-owned session/update siblings. let methods: Vec<&str> = replay .iter() @@ -1625,15 +1625,15 @@ async fn replay_log_delivers_history_to_late_joiner() { let pj_idx = methods .iter() - .position(|m| *m == "amux/peer_joined") + .position(|m| *m == "rooms/peer_joined") .expect("replay should contain peer_joined"); let ts_idx = methods .iter() - .position(|m| *m == "amux/turn_started") + .position(|m| *m == "rooms/turn_started") .expect("replay should contain turn_started"); let tc_idx = methods .iter() - .position(|m| *m == "amux/turn_complete") + .position(|m| *m == "rooms/turn_complete") .expect("replay should contain turn_complete"); assert!(pj_idx < ts_idx, "peer_joined before turn_started in replay"); @@ -1710,13 +1710,13 @@ async fn replay_skip_suppresses_legacy_history_but_keeps_context_and_live_frames assert!( b_bootstrap .iter() - .any(|v| v.get("method") == Some(&serde_json::json!("amux/session_context"))), + .any(|v| v.get("method") == Some(&serde_json::json!("rooms/session_context"))), "replay=skip should still receive direct session context: {b_bootstrap:?}", ); assert!( b_bootstrap.iter().all(|v| { v.get("method") != Some(&serde_json::json!("session/update")) - && !(v.get("method") == Some(&serde_json::json!("amux/peer_joined")) + && !(v.get("method") == Some(&serde_json::json!("rooms/peer_joined")) && v["params"]["peerId"] == serde_json::json!("A")) }), "replay=skip should suppress pre-connect legacy replay frames: {b_bootstrap:?}", @@ -1802,15 +1802,15 @@ async fn replay_log_adds_mux_record_metadata_to_late_join_frames() { "mux should not add proxy-owned session/update lifecycle siblings: {update:?}", ); - let amux = &update["params"]["_meta"]["amux"]; - let recorded_at = amux["recordedAt"] + let rooms = &update["params"]["_meta"]["rooms"]; + let recorded_at = rooms["recordedAt"] .as_str() .expect("replay metadata should include recordedAt"); assert!( recorded_at.ends_with('Z') && recorded_at.contains('T'), "recordedAt should be an RFC3339-ish UTC timestamp, got {recorded_at:?}" ); - let seq = amux["replaySeq"] + let seq = rooms["replaySeq"] .as_u64() .expect("replay metadata should include numeric replaySeq"); recorded_ats.push(recorded_at.to_string()); @@ -1833,7 +1833,7 @@ async fn replay_log_adds_mux_record_metadata_to_late_join_frames() { } #[tokio::test] -async fn replay_log_merges_amux_metadata_without_clobbering_existing_meta() { +async fn replay_log_merges_rooms_metadata_without_clobbering_existing_meta() { let (addr, _) = spawn_server_with_cat().await; let url_a = format!("ws://{addr}/acp?room=replay-meta-merge&peer_id=A"); let url_b = format!("ws://{addr}/acp?room=replay-meta-merge&peer_id=B"); @@ -1850,9 +1850,9 @@ async fn replay_log_merges_amux_metadata_without_clobbering_existing_meta() { assert_eq!( live_update["params"] .get("_meta") - .and_then(|m| m.get("amux")), + .and_then(|m| m.get("rooms")), None, - "live fan-out should not gain replay-only amux metadata" + "live fan-out should not gain replay-only rooms metadata" ); let (mut ws_b, _) = tokio_tungstenite::connect_async(url_b).await.unwrap(); @@ -1873,12 +1873,12 @@ async fn replay_log_merges_amux_metadata_without_clobbering_existing_meta() { "replay metadata injection should preserve implementation-specific keys" ); assert!( - replay_update["params"]["_meta"]["amux"]["recordedAt"].is_string(), - "replay metadata should add _meta.amux.recordedAt" + replay_update["params"]["_meta"]["rooms"]["recordedAt"].is_string(), + "replay metadata should add _meta.rooms.recordedAt" ); assert!( - replay_update["params"]["_meta"]["amux"]["replaySeq"].is_u64(), - "replay metadata should add _meta.amux.replaySeq" + replay_update["params"]["_meta"]["rooms"]["replaySeq"].is_u64(), + "replay metadata should add _meta.rooms.replaySeq" ); let _ = ws_a.send(ClientMsg::Close(None)).await; @@ -1919,13 +1919,13 @@ async fn replay_turns_disabled_emits_no_history() { assert!( early .iter() - .any(|v| v.get("method") == Some(&serde_json::json!("amux/session_context"))), + .any(|v| v.get("method") == Some(&serde_json::json!("rooms/session_context"))), "B should receive direct session_context on attach, got {early:?}" ); assert!( early .iter() - .all(|v| v.get("method") == Some(&serde_json::json!("amux/session_context"))), + .all(|v| v.get("method") == Some(&serde_json::json!("rooms/session_context"))), "B should see no replay frames beyond session_context, got {early:?}" ); @@ -1933,11 +1933,11 @@ async fn replay_turns_disabled_emits_no_history() { let _ = ws_b.send(ClientMsg::Close(None)).await; } -/// Chunk 7: amux/turn_started fires before forwarding session/prompt, -/// and amux/turn_complete fires when the matching response arrives. -/// Both broadcast to every subscriber. amuxTurnId bookends the pair. +/// Chunk 7: rooms/turn_started fires before forwarding session/prompt, +/// and rooms/turn_complete fires when the matching response arrives. +/// Both broadcast to every subscriber. roomsTurnId bookends the pair. #[tokio::test] -async fn amux_turn_started_and_complete() { +async fn rooms_turn_started_and_complete() { let (addr, _) = spawn_server_with_mock().await; let url_a = format!("ws://{addr}/acp?room=turn&peer_id=A"); let url_b = format!("ws://{addr}/acp?room=turn&peer_id=B"); @@ -1972,11 +1972,11 @@ async fn amux_turn_started_and_complete() { for (label, frames) in [("A", &a_frames), ("B", &b_frames)] { let started = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/turn_started"))) - .unwrap_or_else(|| panic!("{label} should see amux/turn_started, frames: {frames:?}")); + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_started"))) + .unwrap_or_else(|| panic!("{label} should see rooms/turn_started, frames: {frames:?}")); assert_eq!(started["params"]["peerId"], serde_json::json!("A")); assert_eq!(started["params"]["roomId"], serde_json::json!("turn")); - assert_eq!(started["params"]["amuxTurnId"], serde_json::json!("at-1")); + assert_eq!(started["params"]["roomsTurnId"], serde_json::json!("at-1")); assert_eq!( started["params"]["content"], serde_json::json!([{"type":"text","text":"hi"}]) @@ -1984,9 +1984,11 @@ async fn amux_turn_started_and_complete() { let complete = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/turn_complete"))) - .unwrap_or_else(|| panic!("{label} should see amux/turn_complete, frames: {frames:?}")); - assert_eq!(complete["params"]["amuxTurnId"], serde_json::json!("at-1")); + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_complete"))) + .unwrap_or_else(|| { + panic!("{label} should see rooms/turn_complete, frames: {frames:?}") + }); + assert_eq!(complete["params"]["roomsTurnId"], serde_json::json!("at-1")); assert_eq!( complete["params"]["stopReason"], serde_json::json!("end_turn") @@ -1997,9 +1999,9 @@ async fn amux_turn_started_and_complete() { let _ = ws_b.send(ClientMsg::Close(None)).await; } -/// Chunk 7: amux/session_busy fires alongside the -32001 rejection. +/// Chunk 7: rooms/session_busy fires alongside the -32001 rejection. #[tokio::test] -async fn amux_session_busy_on_concurrent_prompt() { +async fn rooms_session_busy_on_concurrent_prompt() { let (addr, _) = spawn_server_with_mock_env(&[("MOCK_ACP_PROMPT_DELAY_MS", "500")]).await; let url_a = format!("ws://{addr}/acp?room=busy&peer_id=A"); let url_b = format!("ws://{addr}/acp?room=busy&peer_id=B"); @@ -2037,8 +2039,8 @@ async fn amux_session_busy_on_concurrent_prompt() { let b_frames = drain_for(&mut ws_b, Duration::from_secs(2)).await; let busy = b_frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/session_busy"))) - .expect("B should see amux/session_busy"); + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/session_busy"))) + .expect("B should see rooms/session_busy"); assert_eq!(busy["params"]["busy"], serde_json::json!(true)); assert_eq!(busy["params"]["heldBy"], serde_json::json!("A")); @@ -2109,8 +2111,8 @@ async fn assert_busy_session_prompt_rejected(control_prompt: &str) { assert!( b_frames .iter() - .any(|v| v.get("method") == Some(&serde_json::json!("amux/session_busy"))), - "plain session/prompt slash commands should still emit amux/session_busy: {b_frames:?}" + .any(|v| v.get("method") == Some(&serde_json::json!("rooms/session_busy"))), + "plain session/prompt slash commands should still emit rooms/session_busy: {b_frames:?}" ); let _ = drain_for(&mut ws_a, Duration::from_secs(1)).await; @@ -2119,7 +2121,7 @@ async fn assert_busy_session_prompt_rejected(control_prompt: &str) { } #[tokio::test] -async fn amux_steer_active_turn_hard_replaces_after_cancel() { +async fn rooms_steer_active_turn_hard_replaces_after_cancel() { let (addr, _) = spawn_server_with_mock_env(&[ ("MOCK_ACP_PROMPT_DELAY_MS", "120"), ("MOCK_ACP_ECHO_SESSION_CANCELS", "1"), @@ -2154,7 +2156,7 @@ async fn amux_steer_active_turn_hard_replaces_after_cancel() { tokio::time::sleep(Duration::from_millis(30)).await; ws_b.send(ClientMsg::Text( - r#"{"jsonrpc":"2.0","id":200,"method":"amux/steer_active_turn","params":{"sessionId":"sess-mock","text":"revise the approach"}}"#.into(), + r#"{"jsonrpc":"2.0","id":200,"method":"rooms/steer_active_turn","params":{"sessionId":"sess-mock","text":"revise the approach"}}"#.into(), )) .await .unwrap(); @@ -2182,7 +2184,7 @@ async fn amux_steer_active_turn_hard_replaces_after_cancel() { ); let cancelled = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/turn_cancelled"))) + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_cancelled"))) .unwrap_or_else(|| panic!("{label} should see cancelled original turn: {frames:?}")); assert_eq!(cancelled["params"]["cancelledBy"], serde_json::json!("B")); assert_eq!( @@ -2192,14 +2194,14 @@ async fn amux_steer_active_turn_hard_replaces_after_cancel() { let control = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/control_submitted"))) + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/control_submitted"))) .unwrap_or_else(|| panic!("{label} should see hard-steer control event: {frames:?}")); assert_eq!(control["params"]["kind"], serde_json::json!("steer")); assert_eq!(control["params"]["mode"], serde_json::json!("hard")); let turn_started: Vec<_> = frames .iter() - .filter(|v| v.get("method") == Some(&serde_json::json!("amux/turn_started"))) + .filter(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_started"))) .collect(); assert_eq!( turn_started.len(), @@ -2228,7 +2230,7 @@ async fn amux_steer_active_turn_hard_replaces_after_cancel() { let turn_complete_count = frames .iter() - .filter(|v| v.get("method") == Some(&serde_json::json!("amux/turn_complete"))) + .filter(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_complete"))) .count(); assert_eq!( turn_complete_count, 2, @@ -2241,7 +2243,7 @@ async fn amux_steer_active_turn_hard_replaces_after_cancel() { } #[tokio::test] -async fn amux_steer_active_turn_without_active_turn_submits_prompt() { +async fn rooms_steer_active_turn_without_active_turn_submits_prompt() { let (addr, _) = spawn_server_with_mock_env(&[("MOCK_ACP_PROMPT_DELAY_MS", "10")]).await; let url_a = format!("ws://{addr}/acp?room=idle-steer&peer_id=A"); let url_b = format!("ws://{addr}/acp?room=idle-steer&peer_id=B"); @@ -2265,7 +2267,7 @@ async fn amux_steer_active_turn_without_active_turn_submits_prompt() { .await; ws_b.send(ClientMsg::Text( - r#"{"jsonrpc":"2.0","id":200,"method":"amux/steer_active_turn","params":{"sessionId":"sess-mock","text":"start from idle steer"}}"#.into(), + r#"{"jsonrpc":"2.0","id":200,"method":"rooms/steer_active_turn","params":{"sessionId":"sess-mock","text":"start from idle steer"}}"#.into(), )) .await .unwrap(); @@ -2288,7 +2290,7 @@ async fn amux_steer_active_turn_without_active_turn_submits_prompt() { serde_json::json!("submitted") ); assert_eq!( - control_response["result"]["amuxTurnId"], + control_response["result"]["roomsTurnId"], serde_json::json!("at-1") ); assert!( @@ -2299,11 +2301,11 @@ async fn amux_steer_active_turn_without_active_turn_submits_prompt() { for (label, frames) in [("A", &a_frames), ("B", &b_frames)] { let control = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/control_submitted"))) + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/control_submitted"))) .unwrap_or_else(|| panic!("{label} should see idle steer control event: {frames:?}")); assert_eq!(control["params"]["kind"], serde_json::json!("steer")); assert_eq!(control["params"]["mode"], serde_json::json!("prompt")); - assert_eq!(control["params"]["amuxTurnId"], serde_json::json!("at-1")); + assert_eq!(control["params"]["roomsTurnId"], serde_json::json!("at-1")); assert_eq!( control["params"]["text"], serde_json::json!("start from idle steer") @@ -2312,7 +2314,7 @@ async fn amux_steer_active_turn_without_active_turn_submits_prompt() { assert!( frames .iter() - .all(|v| v.get("method") != Some(&serde_json::json!("amux/turn_cancelled"))), + .all(|v| v.get("method") != Some(&serde_json::json!("rooms/turn_cancelled"))), "idle steer must not emit cancellation: {frames:?}" ); assert!( @@ -2323,19 +2325,19 @@ async fn amux_steer_active_turn_without_active_turn_submits_prompt() { ); assert!( frames.iter().all(|v| { - v.get("method") != Some(&serde_json::json!("amux/queue_item_added")) - && v.get("method") != Some(&serde_json::json!("amux/queue_item_submitted")) - && v.get("method") != Some(&serde_json::json!("amux/queue_item_completed")) + v.get("method") != Some(&serde_json::json!("rooms/queue_item_added")) + && v.get("method") != Some(&serde_json::json!("rooms/queue_item_submitted")) + && v.get("method") != Some(&serde_json::json!("rooms/queue_item_completed")) }), "idle steer should not use public queue lifecycle: {frames:?}" ); let turn_started = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/turn_started"))) + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_started"))) .unwrap_or_else(|| panic!("{label} should see idle steer turn start: {frames:?}")); assert_eq!( - turn_started["params"]["amuxTurnId"], + turn_started["params"]["roomsTurnId"], serde_json::json!("at-1") ); assert_eq!(turn_started["params"]["peerId"], serde_json::json!("B")); @@ -2350,9 +2352,12 @@ async fn amux_steer_active_turn_without_active_turn_submits_prompt() { let completed = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/turn_complete"))) + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_complete"))) .unwrap_or_else(|| panic!("{label} should see idle steer completion: {frames:?}")); - assert_eq!(completed["params"]["amuxTurnId"], serde_json::json!("at-1")); + assert_eq!( + completed["params"]["roomsTurnId"], + serde_json::json!("at-1") + ); } let _ = ws_a.send(ClientMsg::Close(None)).await; @@ -2360,7 +2365,7 @@ async fn amux_steer_active_turn_without_active_turn_submits_prompt() { } #[tokio::test] -async fn amux_steer_active_turn_rejects_second_pending_hard_steer_until_replacement_pops() { +async fn rooms_steer_active_turn_rejects_second_pending_hard_steer_until_replacement_pops() { let (addr, _) = spawn_server_with_mock_env(&[("MOCK_ACP_PROMPT_DELAY_MS", "350")]).await; let url_a = format!("ws://{addr}/acp?room=busy-fixes&peer_id=A"); let url_b = format!("ws://{addr}/acp?room=busy-fixes&peer_id=B"); @@ -2392,7 +2397,7 @@ async fn amux_steer_active_turn_rejects_second_pending_hard_steer_until_replacem let first = ws_request( &mut ws_b, - r#"{"jsonrpc":"2.0","id":200,"method":"amux/steer_active_turn","params":{"sessionId":"sess-mock","text":"first steer"}}"#, + r#"{"jsonrpc":"2.0","id":200,"method":"rooms/steer_active_turn","params":{"sessionId":"sess-mock","text":"first steer"}}"#, ) .await; assert_eq!(first["result"]["mode"], serde_json::json!("hard")); @@ -2403,7 +2408,7 @@ async fn amux_steer_active_turn_rejects_second_pending_hard_steer_until_replacem let second = ws_request( &mut ws_b, - r#"{"jsonrpc":"2.0","id":201,"method":"amux/steer_active_turn","params":{"sessionId":"sess-mock","text":"second steer too soon"}}"#, + r#"{"jsonrpc":"2.0","id":201,"method":"rooms/steer_active_turn","params":{"sessionId":"sess-mock","text":"second steer too soon"}}"#, ) .await; assert_eq!(second["error"]["code"], serde_json::json!(-32002)); @@ -2418,15 +2423,15 @@ async fn amux_steer_active_turn_rejects_second_pending_hard_steer_until_replacem std::time::Instant::now() < deadline, "timed out waiting for hard-steer replacement turn to pop" ); - let started = ws_next_method(&mut ws_b, "amux/turn_started").await; - if started["params"]["amuxTurnId"] == serde_json::json!("at-2") { + let started = ws_next_method(&mut ws_b, "rooms/turn_started").await; + if started["params"]["roomsTurnId"] == serde_json::json!("at-2") { break; } } let third = ws_request( &mut ws_b, - r#"{"jsonrpc":"2.0","id":202,"method":"amux/steer_active_turn","params":{"sessionId":"sess-mock","text":"steer replacement"}}"#, + r#"{"jsonrpc":"2.0","id":202,"method":"rooms/steer_active_turn","params":{"sessionId":"sess-mock","text":"steer replacement"}}"#, ) .await; assert_eq!(third["result"]["mode"], serde_json::json!("hard")); @@ -2441,7 +2446,7 @@ async fn amux_steer_active_turn_rejects_second_pending_hard_steer_until_replacem } #[tokio::test] -async fn amux_queue_prompt_is_mux_owned_and_replays_lifecycle() { +async fn rooms_queue_prompt_is_mux_owned_and_replays_lifecycle() { let (addr, _) = spawn_server_with_mock_env(&[("MOCK_ACP_PROMPT_DELAY_MS", "120")]).await; let url_a = format!("ws://{addr}/acp?room=mux-owned-queue&peer_id=A"); let url_b = format!("ws://{addr}/acp?room=mux-owned-queue&peer_id=B"); @@ -2472,7 +2477,7 @@ async fn amux_queue_prompt_is_mux_owned_and_replays_lifecycle() { .unwrap(); tokio::time::sleep(Duration::from_millis(30)).await; ws_b.send(ClientMsg::Text( - r#"{"jsonrpc":"2.0","id":200,"method":"amux/queue_prompt","params":{"sessionId":"sess-mock","text":"do this next"}}"#.into(), + r#"{"jsonrpc":"2.0","id":200,"method":"rooms/queue_prompt","params":{"sessionId":"sess-mock","text":"do this next"}}"#.into(), )) .await .unwrap(); @@ -2494,7 +2499,7 @@ async fn amux_queue_prompt_is_mux_owned_and_replays_lifecycle() { for (label, frames) in [("A", &a_frames), ("B", &b_frames)] { let added = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/queue_item_added"))) + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/queue_item_added"))) .unwrap_or_else(|| panic!("{label} should see queue item added: {frames:?}")); assert_eq!(added["params"]["queueItemId"], serde_json::json!("q-1")); assert_eq!(added["params"]["peerId"], serde_json::json!("B")); @@ -2502,21 +2507,27 @@ async fn amux_queue_prompt_is_mux_owned_and_replays_lifecycle() { let submitted = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/queue_item_submitted"))) + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/queue_item_submitted"))) .unwrap_or_else(|| panic!("{label} should see queue item submitted: {frames:?}")); assert_eq!(submitted["params"]["queueItemId"], serde_json::json!("q-1")); - assert_eq!(submitted["params"]["amuxTurnId"], serde_json::json!("at-2")); + assert_eq!( + submitted["params"]["roomsTurnId"], + serde_json::json!("at-2") + ); let completed = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/queue_item_completed"))) + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/queue_item_completed"))) .unwrap_or_else(|| panic!("{label} should see queue item completed: {frames:?}")); assert_eq!(completed["params"]["queueItemId"], serde_json::json!("q-1")); - assert_eq!(completed["params"]["amuxTurnId"], serde_json::json!("at-2")); + assert_eq!( + completed["params"]["roomsTurnId"], + serde_json::json!("at-2") + ); let turn_started: Vec<_> = frames .iter() - .filter(|v| v.get("method") == Some(&serde_json::json!("amux/turn_started"))) + .filter(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_started"))) .collect(); assert_eq!( turn_started.len(), @@ -2538,9 +2549,9 @@ async fn amux_queue_prompt_is_mux_owned_and_replays_lifecycle() { .collect(); for expected in [ - "amux/queue_item_added", - "amux/queue_item_submitted", - "amux/queue_item_completed", + "rooms/queue_item_added", + "rooms/queue_item_submitted", + "rooms/queue_item_completed", ] { assert!( methods.contains(&expected), @@ -2550,7 +2561,7 @@ async fn amux_queue_prompt_is_mux_owned_and_replays_lifecycle() { assert_eq!( methods .iter() - .filter(|method| **method == "amux/turn_started") + .filter(|method| **method == "rooms/turn_started") .count(), 2, "late joiner should replay original and queued turn starts: {replay:?}" @@ -2558,7 +2569,7 @@ async fn amux_queue_prompt_is_mux_owned_and_replays_lifecycle() { assert_eq!( methods .iter() - .filter(|method| **method == "amux/turn_complete") + .filter(|method| **method == "rooms/turn_complete") .count(), 2, "late joiner should replay original and queued turn completions: {replay:?}" @@ -2576,7 +2587,7 @@ async fn amux_queue_prompt_is_mux_owned_and_replays_lifecycle() { } #[tokio::test] -async fn amux_queue_prompt_without_active_turn_submits_immediately() { +async fn rooms_queue_prompt_without_active_turn_submits_immediately() { let (addr, _) = spawn_server_with_mock_env(&[("MOCK_ACP_PROMPT_DELAY_MS", "10")]).await; let url_a = format!("ws://{addr}/acp?room=queue_idle&peer_id=A"); let url_b = format!("ws://{addr}/acp?room=queue_idle&peer_id=B"); @@ -2600,7 +2611,7 @@ async fn amux_queue_prompt_without_active_turn_submits_immediately() { .await; ws_b.send(ClientMsg::Text( - r#"{"jsonrpc":"2.0","id":200,"method":"amux/queue_prompt","params":{"sessionId":"sess-mock","text":"start from idle"}}"#.into(), + r#"{"jsonrpc":"2.0","id":200,"method":"rooms/queue_prompt","params":{"sessionId":"sess-mock","text":"start from idle"}}"#.into(), )) .await .unwrap(); @@ -2622,7 +2633,7 @@ async fn amux_queue_prompt_without_active_turn_submits_immediately() { for (label, frames) in [("A", &a_frames), ("B", &b_frames)] { let added = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/queue_item_added"))) + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/queue_item_added"))) .unwrap_or_else(|| panic!("{label} should see queue item added: {frames:?}")); assert_eq!(added["params"]["queueItemId"], serde_json::json!("q-1")); assert_eq!(added["params"]["peerId"], serde_json::json!("B")); @@ -2633,12 +2644,12 @@ async fn amux_queue_prompt_without_active_turn_submits_immediately() { let turn_started = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/turn_started"))) + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_started"))) .unwrap_or_else(|| { panic!("{label} should see immediate queued turn start: {frames:?}") }); assert_eq!( - turn_started["params"]["amuxTurnId"], + turn_started["params"]["roomsTurnId"], serde_json::json!("at-1") ); assert_eq!(turn_started["params"]["peerId"], serde_json::json!("B")); @@ -2649,17 +2660,23 @@ async fn amux_queue_prompt_without_active_turn_submits_immediately() { let submitted = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/queue_item_submitted"))) + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/queue_item_submitted"))) .unwrap_or_else(|| panic!("{label} should see queue item submitted: {frames:?}")); assert_eq!(submitted["params"]["queueItemId"], serde_json::json!("q-1")); - assert_eq!(submitted["params"]["amuxTurnId"], serde_json::json!("at-1")); + assert_eq!( + submitted["params"]["roomsTurnId"], + serde_json::json!("at-1") + ); let completed = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/queue_item_completed"))) + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/queue_item_completed"))) .unwrap_or_else(|| panic!("{label} should see queue item completed: {frames:?}")); assert_eq!(completed["params"]["queueItemId"], serde_json::json!("q-1")); - assert_eq!(completed["params"]["amuxTurnId"], serde_json::json!("at-1")); + assert_eq!( + completed["params"]["roomsTurnId"], + serde_json::json!("at-1") + ); } let _ = ws_a.send(ClientMsg::Close(None)).await; @@ -2667,7 +2684,7 @@ async fn amux_queue_prompt_without_active_turn_submits_immediately() { } #[tokio::test] -async fn amux_queue_prompt_rejects_seventh_pending_item() { +async fn rooms_queue_prompt_rejects_seventh_pending_item() { let (addr, _) = spawn_server_with_mock_env(&[("MOCK_ACP_PROMPT_DELAY_MS", "2000")]).await; let url_a = format!("ws://{addr}/acp?room=busy-fixes&peer_id=A"); let url_b = format!("ws://{addr}/acp?room=busy-fixes&peer_id=B"); @@ -2699,7 +2716,7 @@ async fn amux_queue_prompt_rejects_seventh_pending_item() { for i in 1..=6 { let payload = format!( - r#"{{"jsonrpc":"2.0","id":{},"method":"amux/queue_prompt","params":{{"sessionId":"sess-mock","text":"queued {i}"}}}}"#, + r#"{{"jsonrpc":"2.0","id":{},"method":"rooms/queue_prompt","params":{{"sessionId":"sess-mock","text":"queued {i}"}}}}"#, 200 + i ); let response = ws_request(&mut ws_b, &payload).await; @@ -2712,7 +2729,7 @@ async fn amux_queue_prompt_rejects_seventh_pending_item() { let seventh = ws_request( &mut ws_b, - r#"{"jsonrpc":"2.0","id":300,"method":"amux/queue_prompt","params":{"sessionId":"sess-mock","text":"queued 7"}}"#, + r#"{"jsonrpc":"2.0","id":300,"method":"rooms/queue_prompt","params":{"sessionId":"sess-mock","text":"queued 7"}}"#, ) .await; assert_eq!(seventh["error"]["code"], serde_json::json!(-32003)); @@ -2723,7 +2740,7 @@ async fn amux_queue_prompt_rejects_seventh_pending_item() { } #[tokio::test] -async fn amux_unqueue_prompt_removes_pending_item_and_replays_removal() { +async fn rooms_unqueue_prompt_removes_pending_item_and_replays_removal() { let (addr, _) = spawn_server_with_mock_env(&[("MOCK_ACP_PROMPT_DELAY_MS", "600")]).await; let url_a = format!("ws://{addr}/acp?room=busy-fixes&peer_id=A"); let url_b = format!("ws://{addr}/acp?room=busy-fixes&peer_id=B"); @@ -2756,13 +2773,13 @@ async fn amux_unqueue_prompt_removes_pending_item_and_replays_removal() { let queued = ws_request( &mut ws_b, - r#"{"jsonrpc":"2.0","id":200,"method":"amux/queue_prompt","params":{"sessionId":"sess-mock","text":"do not run"}}"#, + r#"{"jsonrpc":"2.0","id":200,"method":"rooms/queue_prompt","params":{"sessionId":"sess-mock","text":"do not run"}}"#, ) .await; assert_eq!(queued["result"]["queueItemId"], serde_json::json!("q-1")); ws_a.send(ClientMsg::Text( - r#"{"jsonrpc":"2.0","id":300,"method":"amux/unqueue_prompt","params":{"queueItemId":"q-1"}}"#.into(), + r#"{"jsonrpc":"2.0","id":300,"method":"rooms/unqueue_prompt","params":{"queueItemId":"q-1"}}"#.into(), )) .await .unwrap(); @@ -2779,7 +2796,7 @@ async fn amux_unqueue_prompt_removes_pending_item_and_replays_removal() { for (label, frames) in [("A", &a_frames), ("B", &b_frames)] { assert!( frames.iter().any(|v| { - v.get("method") == Some(&serde_json::json!("amux/queue_item_removed")) + v.get("method") == Some(&serde_json::json!("rooms/queue_item_removed")) && v["params"]["queueItemId"] == serde_json::json!("q-1") && v["params"]["removedBy"] == serde_json::json!("A") }), @@ -2787,7 +2804,7 @@ async fn amux_unqueue_prompt_removes_pending_item_and_replays_removal() { ); assert!( frames.iter().all(|v| { - !(v.get("method") == Some(&serde_json::json!("amux/queue_item_submitted")) + !(v.get("method") == Some(&serde_json::json!("rooms/queue_item_submitted")) && v["params"]["queueItemId"] == serde_json::json!("q-1")) }), "removed queue item must not submit: {frames:?}" @@ -2798,14 +2815,14 @@ async fn amux_unqueue_prompt_removes_pending_item_and_replays_removal() { let replay = drain_for(&mut ws_c, Duration::from_millis(400)).await; assert!( replay.iter().any(|v| { - v.get("method") == Some(&serde_json::json!("amux/queue_item_removed")) + v.get("method") == Some(&serde_json::json!("rooms/queue_item_removed")) && v["params"]["queueItemId"] == serde_json::json!("q-1") }), "late joiner should replay queue removal: {replay:?}" ); assert!( replay.iter().all(|v| { - !(v.get("method") == Some(&serde_json::json!("amux/queue_item_submitted")) + !(v.get("method") == Some(&serde_json::json!("rooms/queue_item_submitted")) && v["params"]["queueItemId"] == serde_json::json!("q-1")) }), "late replay must not include submission for removed queue item: {replay:?}" @@ -2817,7 +2834,7 @@ async fn amux_unqueue_prompt_removes_pending_item_and_replays_removal() { } #[tokio::test] -async fn amux_unqueue_prompt_missing_item_uses_queue_not_found_error() { +async fn rooms_unqueue_prompt_missing_item_uses_queue_not_found_error() { let (addr, _) = spawn_server_with_mock_env(&[]).await; let url = format!("ws://{addr}/acp?room=busy-fixes&peer_id=A"); @@ -2826,7 +2843,7 @@ async fn amux_unqueue_prompt_missing_item_uses_queue_not_found_error() { let response = ws_request( &mut ws, - r#"{"jsonrpc":"2.0","id":300,"method":"amux/unqueue_prompt","params":{"queueItemId":"q-missing"}}"#, + r#"{"jsonrpc":"2.0","id":300,"method":"rooms/unqueue_prompt","params":{"queueItemId":"q-missing"}}"#, ) .await; @@ -2841,7 +2858,7 @@ async fn amux_unqueue_prompt_missing_item_uses_queue_not_found_error() { } #[tokio::test] -async fn amux_disconnected_queue_owner_persists_without_becoming_driver() { +async fn rooms_disconnected_queue_owner_persists_without_becoming_driver() { let (addr, _) = spawn_server_with_mock_env(&[("MOCK_ACP_PROMPT_DELAY_MS", "500")]).await; let url_a = format!("ws://{addr}/acp?room=busy-fixes&peer_id=A"); let url_b = format!("ws://{addr}/acp?room=busy-fixes&peer_id=B"); @@ -2873,7 +2890,7 @@ async fn amux_disconnected_queue_owner_persists_without_becoming_driver() { let queued = ws_request( &mut ws_b, - r#"{"jsonrpc":"2.0","id":200,"method":"amux/queue_prompt","params":{"sessionId":"sess-mock","text":"queued after disconnect"}}"#, + r#"{"jsonrpc":"2.0","id":200,"method":"rooms/queue_prompt","params":{"sessionId":"sess-mock","text":"queued after disconnect"}}"#, ) .await; assert_eq!(queued["result"]["queueItemId"], serde_json::json!("q-1")); @@ -2883,7 +2900,7 @@ async fn amux_disconnected_queue_owner_persists_without_becoming_driver() { let after_detach = drain_for(&mut ws_a, Duration::from_millis(200)).await; assert!( after_detach.iter().any(|v| { - v.get("method") == Some(&serde_json::json!("amux/queue_item_orphaned")) + v.get("method") == Some(&serde_json::json!("rooms/queue_item_orphaned")) && v["params"]["queueItemId"] == serde_json::json!("q-1") && v["params"]["peerId"] == serde_json::json!("B") }), @@ -2896,8 +2913,8 @@ async fn amux_disconnected_queue_owner_persists_without_becoming_driver() { std::time::Instant::now() < deadline, "timed out waiting for disconnected owner's queued prompt to submit" ); - let started = ws_next_method(&mut ws_a, "amux/turn_started").await; - if started["params"]["amuxTurnId"] == serde_json::json!("at-2") { + let started = ws_next_method(&mut ws_a, "rooms/turn_started").await; + if started["params"]["roomsTurnId"] == serde_json::json!("at-2") { assert_eq!(started["params"]["peerId"], serde_json::json!("B")); break; } @@ -2973,8 +2990,8 @@ async fn busy_multimodal_control_prompt_still_rejected() { assert!( b_frames .iter() - .any(|v| v.get("method") == Some(&serde_json::json!("amux/session_busy"))), - "non-text busy control prompts should still emit amux/session_busy: {b_frames:?}" + .any(|v| v.get("method") == Some(&serde_json::json!("rooms/session_busy"))), + "non-text busy control prompts should still emit rooms/session_busy: {b_frames:?}" ); let _ = drain_for(&mut ws_a, Duration::from_secs(1)).await; @@ -3304,7 +3321,7 @@ async fn agent_request_first_reply_wins() { ); // Both reply with spec-shaped outcomes. The agent should accept - // exactly one of them; amux/agent_request_resolved should echo + // exactly one of them; rooms/agent_request_resolved should echo // whichever result was forwarded. let reply_a = format!( r#"{{"jsonrpc":"2.0","id":{perm_id_a},"result":{{"outcome":{{"outcome":"selected","optionId":"allow_once"}}}}}}"#, @@ -3358,7 +3375,7 @@ async fn agent_request_first_reply_wins() { "agent must receive exactly one reply for permission id; A frames: {a_frames:?}", ); - // Both peers should also see exactly one amux/agent_request_resolved + // Both peers should also see exactly one rooms/agent_request_resolved // for the resolved permission id, carrying the winning result and // the resolving peer's id. fn resolved_for<'a>( @@ -3368,7 +3385,7 @@ async fn agent_request_first_reply_wins() { frames .iter() .filter(|v| { - v.get("method") == Some(&serde_json::json!("amux/agent_request_resolved")) + v.get("method") == Some(&serde_json::json!("rooms/agent_request_resolved")) && &v["params"]["requestId"] == req_id }) .collect() @@ -3378,12 +3395,12 @@ async fn agent_request_first_reply_wins() { assert_eq!( a_resolved.len(), 1, - "A must see exactly one amux/agent_request_resolved; frames: {a_frames:?}" + "A must see exactly one rooms/agent_request_resolved; frames: {a_frames:?}" ); assert_eq!( b_resolved.len(), 1, - "B must see exactly one amux/agent_request_resolved; frames: {b_frames:?}" + "B must see exactly one rooms/agent_request_resolved; frames: {b_frames:?}" ); let resolver = a_resolved[0]["params"]["resolvedBy"] .as_str() @@ -3403,7 +3420,7 @@ async fn agent_request_first_reply_wins() { ); assert_eq!( a_resolved[0], b_resolved[0], - "A and B must see identical amux/agent_request_resolved frames" + "A and B must see identical rooms/agent_request_resolved frames" ); let _ = ws_a.send(ClientMsg::Close(None)).await; @@ -3413,7 +3430,7 @@ async fn agent_request_first_reply_wins() { /// When a turn completes with an agent-initiated request still /// outstanding (no peer ever replied — the agent's own deadline /// fired and it carried on), the mux must sweep the entry and -/// broadcast `amux/agent_request_resolved { resolvedBy: +/// broadcast `rooms/agent_request_resolved { resolvedBy: /// "mux:turn-ended" }` so peers can dismiss the stale UI. #[tokio::test] async fn agent_request_resolved_on_turn_end_when_no_reply() { @@ -3468,23 +3485,23 @@ async fn agent_request_resolved_on_turn_end_when_no_reply() { ); // Both peers must see the inert, replayable - // amux/agent_request_opened before the cleanup resolution. The raw + // rooms/agent_request_opened before the cleanup resolution. The raw // session/request_permission stays live-only; the opened sibling is // the durable audit context for replay clients. fn find_opened(frames: &[serde_json::Value], req_id: &serde_json::Value) -> Option { frames.iter().position(|v| { - v.get("method") == Some(&serde_json::json!("amux/agent_request_opened")) + v.get("method") == Some(&serde_json::json!("rooms/agent_request_opened")) && &v["params"]["requestId"] == req_id }) } // Both peers must see exactly one cleanup - // amux/agent_request_resolved with resolvedBy=mux:turn-ended + // rooms/agent_request_resolved with resolvedBy=mux:turn-ended // for that id, and it must appear after opened but before - // amux/turn_complete. + // rooms/turn_complete. fn find_resolved(frames: &[serde_json::Value], req_id: &serde_json::Value) -> Option { frames.iter().position(|v| { - v.get("method") == Some(&serde_json::json!("amux/agent_request_resolved")) + v.get("method") == Some(&serde_json::json!("rooms/agent_request_resolved")) && &v["params"]["requestId"] == req_id && v["params"]["resolvedBy"] == serde_json::json!("mux:turn-ended") }) @@ -3492,18 +3509,18 @@ async fn agent_request_resolved_on_turn_end_when_no_reply() { fn find_turn_complete(frames: &[serde_json::Value]) -> Option { frames .iter() - .position(|v| v.get("method") == Some(&serde_json::json!("amux/turn_complete"))) + .position(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_complete"))) } for (label, frames) in [("A", &a_frames), ("B", &b_frames)] { let opened_idx = find_opened(frames, &perm_id_a).unwrap_or_else(|| { - panic!("{label}: missing amux/agent_request_opened; frames: {frames:?}") + panic!("{label}: missing rooms/agent_request_opened; frames: {frames:?}") }); let resolved_idx = find_resolved(frames, &perm_id_a).unwrap_or_else(|| { panic!("{label}: missing mux:turn-ended cleanup; frames: {frames:?}") }); let turn_complete_idx = find_turn_complete(frames) - .unwrap_or_else(|| panic!("{label}: missing amux/turn_complete; frames: {frames:?}")); + .unwrap_or_else(|| panic!("{label}: missing rooms/turn_complete; frames: {frames:?}")); assert!( opened_idx < resolved_idx, "{label}: opened must precede cleanup; opened@{opened_idx} resolved@{resolved_idx}", @@ -3524,8 +3541,8 @@ async fn agent_request_resolved_on_turn_end_when_no_reply() { "{label}: opened should carry enough original request context for replay UI" ); assert!( - opened["params"].get("amuxTurnId").is_some(), - "{label}: opened should be associated with the active amux turn" + opened["params"].get("roomsTurnId").is_some(), + "{label}: opened should be associated with the active rooms turn" ); // result and error are both absent on the cleanup broadcast. let resolved = &frames[resolved_idx]; @@ -3544,7 +3561,7 @@ async fn agent_request_resolved_on_turn_end_when_no_reply() { } /// Resolved agent-initiated requests should replay as an inert -/// amux/agent_request_opened + amux/agent_request_resolved lifecycle pair, +/// rooms/agent_request_opened + rooms/agent_request_resolved lifecycle pair, /// not as a stale actionable JSON-RPC request. Live subscribers still see /// and answer the raw session/request_permission exactly once. #[tokio::test] @@ -3614,20 +3631,22 @@ async fn agent_request_opened_replayed_to_late_joiner_without_actionable_request let opened_idx = replay_frames .iter() .position(|v| { - v.get("method") == Some(&serde_json::json!("amux/agent_request_opened")) + v.get("method") == Some(&serde_json::json!("rooms/agent_request_opened")) && v["params"]["requestId"] == perm_id }) .unwrap_or_else(|| { - panic!("late joiner must replay amux/agent_request_opened; frames: {replay_frames:?}") + panic!("late joiner must replay rooms/agent_request_opened; frames: {replay_frames:?}") }); let resolved_idx = replay_frames .iter() .position(|v| { - v.get("method") == Some(&serde_json::json!("amux/agent_request_resolved")) + v.get("method") == Some(&serde_json::json!("rooms/agent_request_resolved")) && v["params"]["requestId"] == perm_id }) .unwrap_or_else(|| { - panic!("late joiner must replay amux/agent_request_resolved; frames: {replay_frames:?}") + panic!( + "late joiner must replay rooms/agent_request_resolved; frames: {replay_frames:?}" + ) }); assert!( opened_idx < resolved_idx, @@ -3976,7 +3995,7 @@ async fn subscriber_cancels_own_prompt_translated_to_agent() { ) .await; - // Send the prompt. id=42 is the subscriber's id; amux rewrites to a + // Send the prompt. id=42 is the subscriber's id; rooms rewrites to a // mux_id internally. Don't await its response — we want to cancel // while it's in flight. ws.send(ClientMsg::Text( @@ -4046,7 +4065,7 @@ async fn subscriber_cancel_unknown_id_dropped() { /// Subscriber B sending `$/cancel_request` with subscriber A's /// original id finds no pending entry under (B, A's id) and is dropped /// silently. A's request continues uninterrupted. (B should use -/// `amux/cancel_active_turn` for cross-peer cancel.) +/// `rooms/cancel_active_turn` for cross-peer cancel.) #[tokio::test] async fn subscriber_cannot_cancel_another_subscribers_request() { let (addr, _) = spawn_server_with_mock_env(&[ @@ -4117,8 +4136,8 @@ async fn subscriber_cannot_cancel_another_subscribers_request() { /// Agent-emitted `$/cancel_request` for an in-flight agent-initiated /// request (e.g. `session/request_permission`) is preceded by inert -/// `amux/agent_request_opened`, forwarded to every subscriber, and -/// mirrored by `amux/agent_request_resolved { resolvedBy: +/// `rooms/agent_request_opened`, forwarded to every subscriber, and +/// mirrored by `rooms/agent_request_resolved { resolvedBy: /// "agent:cancelled" }`. Subsequent subscriber replies for the same id /// are dropped via the first-writer-wins gate. #[tokio::test] @@ -4171,10 +4190,10 @@ async fn agent_cancels_permission_request_fans_out() { let opened_idx = frames .iter() .position(|v| { - v.get("method") == Some(&serde_json::json!("amux/agent_request_opened")) + v.get("method") == Some(&serde_json::json!("rooms/agent_request_opened")) && &v["params"]["requestId"] == perm_id }) - .unwrap_or_else(|| panic!("{label}: must see amux/agent_request_opened")); + .unwrap_or_else(|| panic!("{label}: must see rooms/agent_request_opened")); let opened = &frames[opened_idx]; assert!( opened_idx < perm_idx, @@ -4208,10 +4227,10 @@ async fn agent_cancels_permission_request_fans_out() { let resolved_idx = frames .iter() .position(|v| { - v.get("method") == Some(&serde_json::json!("amux/agent_request_resolved")) + v.get("method") == Some(&serde_json::json!("rooms/agent_request_resolved")) && &v["params"]["requestId"] == perm_id }) - .unwrap_or_else(|| panic!("{label}: must see amux/agent_request_resolved")); + .unwrap_or_else(|| panic!("{label}: must see rooms/agent_request_resolved")); let resolved = &frames[resolved_idx]; assert!( opened_idx < resolved_idx, @@ -4227,11 +4246,11 @@ async fn agent_cancels_permission_request_fans_out() { let _ = ws_b.send(ClientMsg::Close(None)).await; } -/// `amux/cancel_active_turn` from a non-driver peer broadcasts -/// `amux/turn_cancelled` to every peer AND sends ACP-native +/// `rooms/cancel_active_turn` from a non-driver peer broadcasts +/// `rooms/turn_cancelled` to every peer AND sends ACP-native /// `session/cancel` to the agent using the active turn's `sessionId`. #[tokio::test] -async fn amux_cancel_active_turn_by_non_driver() { +async fn rooms_cancel_active_turn_by_non_driver() { let (addr, _) = spawn_server_with_mock_env(&[ ("MOCK_ACP_ECHO_CANCELS", "1"), ("MOCK_ACP_ECHO_SESSION_CANCELS", "1"), @@ -4270,7 +4289,7 @@ async fn amux_cancel_active_turn_by_non_driver() { // B clicks stop. ws_b.send(ClientMsg::Text( - r#"{"jsonrpc":"2.0","method":"amux/cancel_active_turn","params":{"reason":"user clicked stop"}}"#.into(), + r#"{"jsonrpc":"2.0","method":"rooms/cancel_active_turn","params":{"reason":"user clicked stop"}}"#.into(), )) .await .unwrap(); @@ -4281,8 +4300,8 @@ async fn amux_cancel_active_turn_by_non_driver() { for (label, frames) in [("A", &a_frames), ("B", &b_frames)] { let cancelled = frames .iter() - .find(|v| v.get("method") == Some(&serde_json::json!("amux/turn_cancelled"))) - .unwrap_or_else(|| panic!("{label}: must see amux/turn_cancelled; got {frames:?}")); + .find(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_cancelled"))) + .unwrap_or_else(|| panic!("{label}: must see rooms/turn_cancelled; got {frames:?}")); assert_eq!(cancelled["params"]["cancelledBy"], serde_json::json!("B")); assert_eq!( cancelled["params"]["originalDriver"], @@ -4311,17 +4330,17 @@ async fn amux_cancel_active_turn_by_non_driver() { .any(|v| v.get("method") == Some(&serde_json::json!("mock/cancel_echo"))); assert!( !saw_request_cancel_echo, - "amux/cancel_active_turn must not use $/cancel_request for active prompts" + "rooms/cancel_active_turn must not use $/cancel_request for active prompts" ); let _ = ws_a.send(ClientMsg::Close(None)).await; let _ = ws_b.send(ClientMsg::Close(None)).await; } -/// `amux/cancel_active_turn` with no active turn is dropped silently — +/// `rooms/cancel_active_turn` with no active turn is dropped silently — /// no broadcast, no agent traffic. #[tokio::test] -async fn amux_cancel_active_turn_with_no_active_turn_dropped() { +async fn rooms_cancel_active_turn_with_no_active_turn_dropped() { let (addr, _) = spawn_server_with_mock_env(&[ ("MOCK_ACP_ECHO_CANCELS", "1"), ("MOCK_ACP_ECHO_SESSION_CANCELS", "1"), @@ -4332,7 +4351,7 @@ async fn amux_cancel_active_turn_with_no_active_turn_dropped() { let _ = ws_request(&mut ws, r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#).await; ws.send(ClientMsg::Text( - r#"{"jsonrpc":"2.0","method":"amux/cancel_active_turn"}"#.into(), + r#"{"jsonrpc":"2.0","method":"rooms/cancel_active_turn"}"#.into(), )) .await .unwrap(); @@ -4340,7 +4359,7 @@ async fn amux_cancel_active_turn_with_no_active_turn_dropped() { let frames = drain_for(&mut ws, Duration::from_millis(400)).await; let saw_cancelled = frames .iter() - .any(|v| v.get("method") == Some(&serde_json::json!("amux/turn_cancelled"))); + .any(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_cancelled"))); let saw_cancel_echo = frames .iter() .any(|v| v.get("method") == Some(&serde_json::json!("mock/cancel_echo"))); @@ -4362,8 +4381,8 @@ async fn amux_cancel_active_turn_with_no_active_turn_dropped() { // ===== session/list tests (issue #5 / session-list RFD) ===== /// When the agent advertises `sessionCapabilities.list` in its -/// `initialize` response, amux passes the capability through to the -/// client verbatim. No injection by amux — the agent owns this +/// `initialize` response, rooms passes the capability through to the +/// client verbatim. No injection by rooms — the agent owns this /// capability. #[tokio::test] async fn session_list_capability_propagates_from_agent() { @@ -4379,7 +4398,7 @@ async fn session_list_capability_propagates_from_agent() { let _ = ws.send(ClientMsg::Close(None)).await; } -/// When the agent does *not* advertise the capability, amux must not +/// When the agent does *not* advertise the capability, rooms must not /// fabricate it — clients that probe see nothing. #[tokio::test] async fn session_list_capability_absent_when_agent_doesnt_advertise() { @@ -4391,23 +4410,23 @@ async fn session_list_capability_absent_when_agent_doesnt_advertise() { resp["result"]["agentCapabilities"]["sessionCapabilities"] .get("list") .is_none(), - "amux must not synthesize sessionCapabilities.list when the agent doesn't advertise it", + "rooms must not synthesize sessionCapabilities.list when the agent doesn't advertise it", ); let _ = ws.send(ClientMsg::Close(None)).await; } -/// End-to-end: client sends `session/list`, amux forwards to the +/// End-to-end: client sends `session/list`, rooms forwards to the /// agent via the normal request path (id translation), agent -/// responds, amux returns the response with the client's original id +/// responds, rooms returns the response with the client's original id /// restored. The session list flows through unmodified. #[tokio::test] -async fn session_list_request_forwards_through_amux() { +async fn session_list_request_forwards_through_rooms() { let (addr, _) = spawn_server_with_mock_env(&[("MOCK_ACP_SESSION_LIST", "1")]).await; let url = format!("ws://{addr}/acp?room=list&peer_id=A"); let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap(); let _ = ws_request(&mut ws, r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#).await; - // Use a string id to also confirm amux's id translation round-trip + // Use a string id to also confirm rooms's id translation round-trip // works for non-numeric ids on this path. let resp = ws_request( &mut ws, @@ -4432,9 +4451,9 @@ async fn session_list_request_forwards_through_amux() { /// Once a room has established its upstream ACP session id via /// `session/new`, `session/list` decorates only the matching live entry -/// under `_meta.amux`, preserving agent-owned `_meta` keys. +/// under `_meta.rooms`, preserving agent-owned `_meta` keys. #[tokio::test] -async fn session_list_decorates_live_entry_with_amux_metadata() { +async fn session_list_decorates_live_entry_with_rooms_metadata() { let (addr, _) = spawn_server_with_mock_env(&[ ("MOCK_ACP_SESSION_LIST", "1"), ("MOCK_ACP_SESSION_LIST_META", "1"), @@ -4473,19 +4492,19 @@ async fn session_list_decorates_live_entry_with_amux_metadata() { .expect("current session entry"); assert_eq!(current["_meta"]["agentKey"], serde_json::json!("preserved")); assert_eq!( - current["_meta"]["amux"]["agentAmuxKey"], + current["_meta"]["rooms"]["agentRoomsKey"], serde_json::json!("preserved") ); assert_eq!( - current["_meta"]["amux"]["roomId"], + current["_meta"]["rooms"]["roomId"], serde_json::json!("live-room") ); assert_eq!( - current["_meta"]["amux"]["subscriberCount"], + current["_meta"]["rooms"]["subscriberCount"], serde_json::json!(2) ); assert_eq!( - current["_meta"]["amux"]["drivingSubscriber"], + current["_meta"]["rooms"]["drivingSubscriber"], serde_json::json!("A") ); @@ -4495,8 +4514,8 @@ async fn session_list_decorates_live_entry_with_amux_metadata() { .find(|s| s["sessionId"] == serde_json::json!(archive_id)) .expect("archive session entry"); assert!( - archived.get("_meta").and_then(|m| m.get("amux")).is_none(), - "non-live session {archive_id} must not receive amux metadata" + archived.get("_meta").and_then(|m| m.get("rooms")).is_none(), + "non-live session {archive_id} must not receive rooms metadata" ); } @@ -4505,7 +4524,7 @@ async fn session_list_decorates_live_entry_with_amux_metadata() { } /// `session/list` with a `cwd` filter is forwarded with the filter -/// intact — amux doesn't interpret the params, the agent does. The +/// intact — rooms doesn't interpret the params, the agent does. The /// mock filters by exact match on `cwd`. #[tokio::test] async fn session_list_with_cwd_filter_forwarded_unmodified() { @@ -4708,7 +4727,7 @@ async fn session_load_replay_generation_excludes_previous_session_updates_for_la ); assert!( replay.iter().any(|v| { - v.get("method") == Some(&serde_json::json!("amux/peer_joined")) + v.get("method") == Some(&serde_json::json!("rooms/peer_joined")) && v["params"]["peerId"] == serde_json::json!("A") }), "replay reset should still teach late joiners about existing peers, got {replay:?}", @@ -4927,7 +4946,7 @@ async fn debug_sessions_shows_loaded_session_id() { /// failure). Path is unique per (pid, nanos, label). fn replay_store_dir(label: &str) -> std::path::PathBuf { let p = std::env::temp_dir().join(format!( - "amux-replay-test-{}-{}-{}", + "rooms-replay-test-{}-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -5078,7 +5097,7 @@ async fn replay_store_replay_turns_zero_writes_no_history() { #[tokio::test] async fn replay_store_preserves_original_recorded_at_across_restart() { - // Criterion: replay metadata still lives under _meta.amux and uses + // Criterion: replay metadata still lives under _meta.rooms and uses // the original mux-record time, not the replay send time. let dir = replay_store_dir("recorded-at"); seed_room_to_disk(&dir, "room26ts", "ts-check").await; @@ -5111,11 +5130,11 @@ async fn replay_store_preserves_original_recorded_at_across_restart() { .iter() .find(|entry| entry["method"] == serde_json::json!("session/update")) .and_then(|entry| { - entry["params"]["_meta"]["amux"]["recordedAt"] + entry["params"]["_meta"]["rooms"]["recordedAt"] .as_str() .map(str::to_string) }) - .expect("replayed session/update carries amux.recordedAt"); + .expect("replayed session/update carries rooms.recordedAt"); assert_eq!( replayed_recorded_at, original_recorded_at, @@ -5198,3 +5217,253 @@ async fn replay_store_session_load_segmentation_excludes_stale_frames() { let _ = ws_b.send(ClientMsg::Close(None)).await; std::fs::remove_dir_all(&dir).ok(); } + +/// `rooms/cancel_active_turn` sent as a JSON-RPC *request* (the documented +/// shape — it carries an `id`) is answered by the mux with a result ack and is +/// NOT forwarded to the agent. Regression guard for the doc/impl mismatch where +/// only the notification shape was handled. +#[tokio::test] +async fn rooms_cancel_active_turn_request_shape_is_answered_by_mux() { + let (addr, _) = spawn_server_with_mock_env(&[ + ("MOCK_ACP_ECHO_SESSION_CANCELS", "1"), + ("MOCK_ACP_PROMPT_DELAY_MS", "1500"), + ]) + .await; + let url = format!("ws://{addr}/acp?room=creq&peer_id=A"); + let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap(); + let _ = ws_request(&mut ws, r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#).await; + let _ = ws_request( + &mut ws, + r#"{"jsonrpc":"2.0","id":2,"method":"session/new"}"#, + ) + .await; + + // Open a turn; do not await its response (the mock holds it for 1500ms). + ws.send(ClientMsg::Text( + r#"{"jsonrpc":"2.0","id":99,"method":"session/prompt","params":{"sessionId":"sess-mock"}}"# + .into(), + )) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(80)).await; + + // Cancel via the DOCUMENTED request shape (has an id). + ws.send(ClientMsg::Text( + r#"{"jsonrpc":"2.0","id":21,"method":"rooms/cancel_active_turn","params":{"reason":"stop"}}"# + .into(), + )) + .await + .unwrap(); + + let frames = drain_for(&mut ws, Duration::from_secs(3)).await; + + // The mux answered the request: a result for id 21 (only the mux produces this). + let ack = frames + .iter() + .find(|v| v.get("id") == Some(&serde_json::json!(21)) && v.get("result").is_some()) + .unwrap_or_else(|| { + panic!("expected a mux result for cancel request id=21; got {frames:?}") + }); + assert_eq!(ack["result"]["status"], serde_json::json!("cancelling")); + + // It was NOT forwarded to the agent: no error response came back for id 21 + // (a forwarded rooms/* request would yield a method-not-found error). + assert!( + !frames + .iter() + .any(|v| v.get("id") == Some(&serde_json::json!(21)) && v.get("error").is_some()), + "cancel request must be answered by the mux, never error back from the agent: {frames:?}", + ); + + // And the cancel actually took effect. + assert!( + frames + .iter() + .any(|v| v.get("method") == Some(&serde_json::json!("rooms/turn_cancelled"))), + "expected rooms/turn_cancelled broadcast", + ); + assert!( + frames + .iter() + .any(|v| v.get("method") == Some(&serde_json::json!("mock/session_cancel_echo"))), + "agent should have received ACP session/cancel", + ); + + let _ = ws.send(ClientMsg::Close(None)).await; +} + +/// `rooms/cancel_active_turn` as a request with no active turn is answered by +/// the mux with -32002 (no active turn), not forwarded to the agent. +#[tokio::test] +async fn rooms_cancel_active_turn_request_with_no_active_turn_errors() { + let (addr, _) = spawn_server_with_mock().await; + let url = format!("ws://{addr}/acp?room=creqnt&peer_id=A"); + let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap(); + let _ = ws_request(&mut ws, r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#).await; + let _ = ws_request( + &mut ws, + r#"{"jsonrpc":"2.0","id":2,"method":"session/new"}"#, + ) + .await; + + let resp = ws_request( + &mut ws, + r#"{"jsonrpc":"2.0","id":7,"method":"rooms/cancel_active_turn"}"#, + ) + .await; + assert_eq!( + resp["error"]["code"], + serde_json::json!(-32002), + "no-active-turn cancel request must be rejected by the mux: {resp:?}", + ); + + let _ = ws.send(ClientMsg::Close(None)).await; +} + +/// `historyPolicy: full` after a NOTIFICATION-driven segment rotation must be +/// scoped to the current segment (not the prior segment's agent chunks), while +/// still carrying the cross-segment `rooms/turn_started` bookend for a turn that +/// spans the rotation. Regression guard for the `replay_generation`-based filter +/// that made `full` behave like `full_lineage` after notification rotation +/// (generation stays 0) and dropped the cross-segment turn-bookend carry. +#[tokio::test] +async fn full_history_after_notification_rotation_is_current_segment_with_carry() { + let (addr, _) = + spawn_server_with_mock_env(&[("MOCK_ACP_ROTATE_SESSION_ID", "sess-rotated")]).await; + + // Driver opens segment 1 (sess-mock) and runs one turn. The mock rotates + // the canonical sessionId mid-turn, so `rooms/turn_started` lands in + // segment 1 and `rooms/turn_complete` in segment 2. + let url_a = format!("ws://{addr}/acp?room=rot&peer_id=A"); + let (mut ws_a, _) = tokio_tungstenite::connect_async(url_a).await.unwrap(); + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, + ) + .await; + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":2,"method":"session/new"}"#, + ) + .await; + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess-mock","prompt":[{"type":"text","text":"hi"}]}}"#, + ) + .await; + + // Late joiner attaches with both policies; `replay=skip` so only the + // session/attach response history is under test. + let url_b = format!("ws://{addr}/acp?room=rot&peer_id=B&replay=skip"); + let (mut ws_b, _) = tokio_tungstenite::connect_async(url_b).await.unwrap(); + let _ = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, + ) + .await; + let full = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":10,"method":"session/attach","params":{"historyPolicy":"full"}}"#, + ) + .await; + let lineage = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":11,"method":"session/attach","params":{"historyPolicy":"full_lineage"}}"#, + ) + .await; + + let full_json = serde_json::to_string(&full["result"]["history"]).unwrap(); + let lineage_json = serde_json::to_string(&lineage["result"]["history"]).unwrap(); + + // Carry: full keeps both bookends for the rotation-spanning turn. + assert!( + full_json.contains("rooms/turn_started"), + "full must carry the prior-segment turn_started: {full_json}", + ); + assert!( + full_json.contains("rooms/turn_complete"), + "full must include the active-segment turn_complete: {full_json}", + ); + // Scoping: full excludes prior-segment agent chunks ("world" is a seg1 + // chunk), but full_lineage includes them. + assert!( + !full_json.contains("world"), + "full must exclude prior-segment agent chunks (not behave like full_lineage): {full_json}", + ); + assert!( + lineage_json.contains("world"), + "full_lineage must include prior-segment agent chunks: {lineage_json}", + ); + // Sanity: current (rotated) segment content IS present in full. + assert!( + full_json.contains("rotated-segment-chunk"), + "full must include current-segment frames: {full_json}", + ); + + let _ = ws_a.send(ClientMsg::Close(None)).await; + let _ = ws_b.send(ClientMsg::Close(None)).await; +} + +/// After a notification-driven sessionId rotation, the core canonical session +/// id tracks the new id, so `session/attach` reports and accepts the current +/// id (not the stale pre-rotation one). Regression guard: the segment used to +/// rotate in the rooms layer while core's canonical stayed pinned to the old +/// id, so attach returned the stale id and rejected the actually-current one. +#[tokio::test] +async fn attach_tracks_canonical_session_id_after_notification_rotation() { + let (addr, _) = + spawn_server_with_mock_env(&[("MOCK_ACP_ROTATE_SESSION_ID", "sess-rotated")]).await; + + let url_a = format!("ws://{addr}/acp?room=cantrack&peer_id=A"); + let (mut ws_a, _) = tokio_tungstenite::connect_async(url_a).await.unwrap(); + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, + ) + .await; + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":2,"method":"session/new"}"#, + ) + .await; + // This turn rotates the canonical sessionId sess-mock -> sess-rotated. + let _ = ws_request( + &mut ws_a, + r#"{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess-mock","prompt":[{"type":"text","text":"hi"}]}}"#, + ) + .await; + + let url_b = format!("ws://{addr}/acp?room=cantrack&peer_id=B&replay=skip"); + let (mut ws_b, _) = tokio_tungstenite::connect_async(url_b).await.unwrap(); + let _ = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, + ) + .await; + + // Attach with no sessionId reports the CURRENT (post-rotation) canonical id. + let attach = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":10,"method":"session/attach","params":{"historyPolicy":"none"}}"#, + ) + .await; + assert_eq!( + attach["result"]["sessionId"], + serde_json::json!("sess-rotated"), + "attach must report the post-rotation canonical sessionId: {attach:?}", + ); + + // Attach explicitly requesting the current id is accepted (not 'not found'). + let attach_current = ws_request( + &mut ws_b, + r#"{"jsonrpc":"2.0","id":11,"method":"session/attach","params":{"sessionId":"sess-rotated","historyPolicy":"none"}}"#, + ) + .await; + assert!( + attach_current.get("result").is_some(), + "the current sessionId must be accepted, not rejected as not-found: {attach_current:?}", + ); + + let _ = ws_a.send(ClientMsg::Close(None)).await; + let _ = ws_b.send(ClientMsg::Close(None)).await; +} diff --git a/docs/client-migration-v0.2-rooms.md b/docs/client-migration-v0.2-rooms.md index 5aee209..7f06b7c 100644 --- a/docs/client-migration-v0.2-rooms.md +++ b/docs/client-migration-v0.2-rooms.md @@ -16,11 +16,11 @@ A room is the thing your WebSocket attaches to. An ACP `sessionId` is the thing 1. Connect with `?room=` instead of `?session=`. 2. Keep `roomId` and `currentAcpSessionId` as separate state fields. -3. Treat `amux/*` frames as mux control/lifecycle metadata, not agent conversation. +3. Treat `rooms/*` frames as mux control/lifecycle metadata, not agent conversation. 4. Use `session/attach` as the bootstrap source when possible. Prefer `replay=skip` on the WebSocket URL so you do not process both legacy WS replay and attach history. 5. Request `historyPolicy: "full_lineage"` when the UI should restore the whole room transcript across ACP session-id rotations. -6. Parse and ignore-or-render `amux/segment_started` / `amux/segment_ended` lifecycle frames. -7. For streamed attach history, branch on `result._meta.amux.appliedHistoryDelivery`; do not assume the server accepted stream mode just because you requested it. +6. Parse and ignore-or-render `rooms/segment_started` / `rooms/segment_ended` lifecycle frames. +7. For streamed attach history, branch on `result._meta.rooms.appliedHistoryDelivery`; do not assume the server accepted stream mode just because you requested it. ## Connect @@ -43,7 +43,7 @@ New shape: Recommended flow: 1. Open WebSocket with `replay=skip`. -2. Wait for direct attach context such as `amux/session_context`. +2. Wait for direct attach context such as `rooms/session_context`. 3. Send `session/attach`. 4. Apply the returned snapshot/roster/history. 5. Continue with normal live frame handling. @@ -58,7 +58,7 @@ Example: "params": { "historyPolicy": "full_lineage", "_meta": { - "amux": { + "rooms": { "replayOrder": "chronological", "historyDelivery": "response" } @@ -79,7 +79,7 @@ Expected response fields: "historyPolicy": "full_lineage", "history": [], "_meta": { - "amux": { + "rooms": { "connectedClients": [], "appliedReplayOrder": "chronological", "appliedHistoryDelivery": "response", @@ -115,12 +115,12 @@ A segment is the interval where one ACP `sessionId` is canonical for the room. T Provider-specific metadata is not a segment signal. -`amux/segment_started`: +`rooms/segment_started`: ```json { "jsonrpc": "2.0", - "method": "amux/segment_started", + "method": "rooms/segment_started", "params": { "roomId": "work", "segmentId": "seg-2", @@ -130,12 +130,12 @@ Provider-specific metadata is not a segment signal. } ``` -`amux/segment_ended`: +`rooms/segment_ended`: ```json { "jsonrpc": "2.0", - "method": "amux/segment_ended", + "method": "rooms/segment_ended", "params": { "roomId": "work", "segmentId": "seg-1", @@ -153,8 +153,8 @@ Provider-specific metadata is not a segment signal. ## Rendering guidance -- Use `amux/turn_started` and `amux/turn_complete` as turn bookends. -- Use `amuxTurnId`, not ACP `sessionId`, to bracket output chunks into a turn. +- Use `rooms/turn_started` and `rooms/turn_complete` as turn bookends. +- Use `roomsTurnId`, not ACP `sessionId`, to bracket output chunks into a turn. - Use `roomId` for room UI and connection state. - Use `currentAcpSessionId` only in ACP payloads (`session/prompt`, `session/load`, `session/cancel`, etc.). - Render segment boundaries as compact session-load/session-rotation dividers if useful, but do not treat them as separate conversations. @@ -162,13 +162,13 @@ Provider-specific metadata is not a segment signal. ## Permission/request handling -Live `session/request_permission` frames are actionable. Replayed `amux/agent_request_opened` frames are not. +Live `session/request_permission` frames are actionable. Replayed `rooms/agent_request_opened` frames are not. Rules: 1. Show an approval UI only for a live or re-issued raw `session/request_permission` request. -2. Treat `amux/agent_request_opened` as context for history/audit. -3. Treat `amux/agent_request_resolved` as a dismissal/update signal. +2. Treat `rooms/agent_request_opened` as context for history/audit. +3. Treat `rooms/agent_request_resolved` as a dismissal/update signal. 4. First valid peer reply wins; late replies may be dropped if another peer already answered. ## Streamed attach history @@ -178,7 +178,7 @@ If requesting stream mode: ```jsonc { "historyPolicy": "full_lineage", - "_meta": { "amux": { "historyDelivery": "stream" } } + "_meta": { "rooms": { "historyDelivery": "stream" } } } ``` @@ -186,7 +186,7 @@ Check the response: ```jsonc "_meta": { - "amux": { + "rooms": { "appliedHistoryDelivery": "stream" } } @@ -194,9 +194,9 @@ Check the response: If accepted, the mux brackets streamed history with: -- `amux/replay_started` +- `rooms/replay_started` - historical frames -- `amux/replay_complete` +- `rooms/replay_complete` If the applied delivery is `response`, read `result.history` instead. @@ -204,9 +204,9 @@ If the applied delivery is `response`, read `result.history` instead. - [ ] URL uses `room`, not `session`. - [ ] Client state separates `roomId`, `currentAcpSessionId`, and `activeSegmentId`. -- [ ] Client filters `amux/*` out of agent conversation rendering. -- [ ] Client handles `amux/turn_started` / `amux/turn_complete` / `amux/turn_cancelled`. -- [ ] Client handles `amux/segment_started` / `amux/segment_ended`. +- [ ] Client filters `rooms/*` out of agent conversation rendering. +- [ ] Client handles `rooms/turn_started` / `rooms/turn_complete` / `rooms/turn_cancelled`. +- [ ] Client handles `rooms/segment_started` / `rooms/segment_ended`. - [ ] Full transcript restore uses `historyPolicy: "full_lineage"`. - [ ] Approval UI only treats raw live/re-issued `session/request_permission` as actionable. - [ ] Streamed attach path checks `appliedHistoryDelivery` before assuming stream markers will arrive. diff --git a/docs/design/amux-namespace.md b/docs/design/rooms-namespace.md similarity index 59% rename from docs/design/amux-namespace.md rename to docs/design/rooms-namespace.md index 0549236..c7c2753 100644 --- a/docs/design/amux-namespace.md +++ b/docs/design/rooms-namespace.md @@ -1,14 +1,14 @@ -# `amux/*` namespace +# `rooms/*` namespace **Status:** v0.2 design surface. -This document describes the optional AMUX collaboration layer. It is not the generic ACP mux contract: raw ACP passthrough, id translation, subprocess routing, safe client-tool defaults, and basic replay belong to the core mux even when a client ignores every `amux/*` frame. +This document describes the optional Rooms collaboration layer. It is not the generic ACP mux contract: raw ACP passthrough, id translation, subprocess routing, safe client-tool defaults, and basic replay belong to the core mux even when a client ignores every `rooms/*` frame. -`acp-mux` mirrors one upstream ACP agent into a multi-client room. The upstream agent owns ACP frames. The AMUX layer owns collaboration facts. Those AMUX-owned facts live under the `amux/*` namespace so clients can tell the two channels apart. +`acp-mux` mirrors one upstream ACP agent into a multi-client room. The upstream agent owns ACP frames. The Rooms layer owns collaboration facts. Those Rooms-owned facts live under the `rooms/*` namespace so clients can tell the two channels apart. ```text session/*, fs/*, terminal/*, ... agent-owned ACP frames -amux/* mux-owned room / replay / control frames +rooms/* mux-owned room / replay / control frames ``` ## Boundary @@ -22,9 +22,9 @@ The generic mux core is intentionally provider-neutral: - do not interpret provider-private metadata to drive room lifecycle; - rotate room segments only on provider-neutral signals: `session/load` or an observable ACP `params.sessionId` change. -The mux MUST NOT fabricate agent-owned `session/*` notifications. If a frame says `method: "session/update"`, it came from the agent. If the AMUX layer needs to say something about peers, turns, replay, queueing, or segment lineage, it emits an `amux/*` frame. +The mux MUST NOT fabricate agent-owned `session/*` notifications. If a frame says `method: "session/update"`, it came from the agent. If the Rooms layer needs to say something about peers, turns, replay, queueing, or segment lineage, it emits a `rooms/*` frame. -Clients that only need the generic mux can treat `amux/*` as extension metadata or request no history/replay where appropriate. Clients that need multiplayer UX should consume this namespace deliberately instead of inferring room state from provider-private payloads. +Clients that only need the generic mux can treat `rooms/*` as extension metadata or request no history/replay where appropriate. Clients that need multiplayer UX should consume this namespace deliberately instead of inferring room state from provider-private payloads. Proxy-local methods such as `session/attach` and `session/detach` are the exception: clients address those requests to the mux, and the mux answers them. They are not forwarded to the wrapped agent and are not pretending to be agent notifications. @@ -32,12 +32,12 @@ Proxy-local methods such as `session/attach` and `session/detach` are the except - **roomId** — stable mux-level collaboration id from `?room=`. - **peerId** — caller-supplied subscriber id, unique within a room. -- **amuxTurnId** — mux turn id formatted as `at-`. +- **roomsTurnId** — mux turn id formatted as `at-`. - **queueItemId** — mux queue item id, currently formatted as `aq-`. - **segmentId** — mux segment id formatted as `seg-`. - **acpSessionId** — upstream ACP `sessionId`, when known. -All `amux/*` payload fields are camelCase. +All `rooms/*` payload fields are camelCase. ## Why a separate namespace @@ -50,10 +50,10 @@ ACP is a 1:1 client/agent protocol. A mux room needs extra facts that ACP itself - whether an old agent request is replay context or still actionable; - when an upstream ACP session id changes inside the same mirrored room. -Keeping these as `amux/*` frames gives clients a clean rule: +Keeping these as `rooms/*` frames gives clients a clean rule: - render ACP frames as agent conversation; -- use `amux/*` frames for room UI, replay bookkeeping, and controls. +- use `rooms/*` frames for room UI, replay bookkeeping, and controls. ## Transport replay vs `session/attach` @@ -79,7 +79,7 @@ Request shape: "sessionId": "", "historyPolicy": "full_lineage", "_meta": { - "amux": { + "rooms": { "replayOrder": "chronological", "historyDelivery": "response" } @@ -100,7 +100,7 @@ Response shape: "historyPolicy": "full_lineage", "history": [ /* optional frames, depending on policy/delivery */ ], "_meta": { - "amux": { + "rooms": { "connectedClients": [ /* roster */ ], "appliedReplayOrder": "chronological", "appliedHistoryDelivery": "response", @@ -125,57 +125,57 @@ Supported `historyPolicy` values: | `pending_only` | Only unresolved permission/request state; not a transcript restore path. | | `after_message` | Accepted as provisional syntax, currently falls back to `full` until stable ACP message ids are available end-to-end. | -Supported `params._meta.amux.replayOrder` values: +Supported `params._meta.rooms.replayOrder` values: | Order | Behavior | |---|---| | `chronological` | Replay frames in durable `replaySeq` order. | | `newest_turn_first` | Keep setup/context frames first, then return completed turn groups newest-first while preserving frame order inside each turn. | -Supported `params._meta.amux.historyDelivery` values: +Supported `params._meta.rooms.historyDelivery` values: | Delivery | Behavior | |---|---| | `response` | Include `history` directly in the attach result. | -| `stream` | Return snapshot metadata immediately, then stream history through `amux/replay_started` and `amux/replay_complete` markers. | +| `stream` | Return snapshot metadata immediately, then stream history through `rooms/replay_started` and `rooms/replay_complete` markers. | ## Proxy-local `session/detach` -`session/detach` is answered by the mux and then the WebSocket closes normally. Remaining peers receive `amux/peer_left`. The mux does not fabricate ACP `session/update` disconnect siblings. +`session/detach` is answered by the mux and then the WebSocket closes normally. Remaining peers receive `rooms/peer_left`. The mux does not fabricate ACP `session/update` disconnect siblings. ## Broadcast notifications -### `amux/session_context` +### `rooms/session_context` Sent to an attaching peer with the local process context inherited by the upstream agent. ```json -{"jsonrpc":"2.0","method":"amux/session_context","params":{"roomId":"work","cwd":"/repo"}} +{"jsonrpc":"2.0","method":"rooms/session_context","params":{"roomId":"work","cwd":"/repo"}} ``` -### `amux/peer_joined` / `amux/peer_left` +### `rooms/peer_joined` / `rooms/peer_left` Presence notifications. ```json -{"jsonrpc":"2.0","method":"amux/peer_joined","params":{"roomId":"work","peerId":"phone","peerName":"Phone","role":"mobile"}} +{"jsonrpc":"2.0","method":"rooms/peer_joined","params":{"roomId":"work","peerId":"phone","peerName":"Phone","role":"mobile"}} ``` ```json -{"jsonrpc":"2.0","method":"amux/peer_left","params":{"roomId":"work","peerId":"phone"}} +{"jsonrpc":"2.0","method":"rooms/peer_left","params":{"roomId":"work","peerId":"phone"}} ``` -### `amux/turn_started` +### `rooms/turn_started` Broadcast before the mux forwards a `session/prompt` to the agent. ```jsonc { "jsonrpc": "2.0", - "method": "amux/turn_started", + "method": "rooms/turn_started", "params": { "roomId": "work", - "amuxTurnId": "at-42", + "roomsTurnId": "at-42", "peerId": "desktop", "peerName": "Desktop", "role": "primary", @@ -187,54 +187,54 @@ Broadcast before the mux forwards a `session/prompt` to the agent. `content` is the originator's `session/prompt.params.prompt` value, mirrored verbatim. `supersedesTurnId` is present for replacement turns created by hard steer. -### `amux/turn_complete` +### `rooms/turn_complete` Broadcast when the active `session/prompt` response lands. ```json -{"jsonrpc":"2.0","method":"amux/turn_complete","params":{"roomId":"work","amuxTurnId":"at-42","stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"rooms/turn_complete","params":{"roomId":"work","roomsTurnId":"at-42","stopReason":"end_turn"}} ``` -### `amux/turn_cancelled` +### `rooms/turn_cancelled` -Intent broadcast emitted immediately when a peer uses `amux/cancel_active_turn` or when hard steer cancels/supersedes an active turn. The later `amux/turn_complete` still marks actual settlement. +Intent broadcast emitted immediately when a peer uses `rooms/cancel_active_turn` or when hard steer cancels/supersedes an active turn. The later `rooms/turn_complete` still marks actual settlement. ```json -{"jsonrpc":"2.0","method":"amux/turn_cancelled","params":{"roomId":"work","amuxTurnId":"at-42","cancelledBy":"phone","originalDriver":"desktop","reason":"user clicked stop"}} +{"jsonrpc":"2.0","method":"rooms/turn_cancelled","params":{"roomId":"work","roomsTurnId":"at-42","cancelledBy":"phone","originalDriver":"desktop","reason":"user clicked stop"}} ``` -### `amux/session_busy` +### `rooms/session_busy` Broadcast when an ordinary `session/prompt` arrives while another turn is active and is rejected with JSON-RPC `-32001`. ```json -{"jsonrpc":"2.0","method":"amux/session_busy","params":{"roomId":"work","busy":true,"heldBy":"desktop"}} +{"jsonrpc":"2.0","method":"rooms/session_busy","params":{"roomId":"work","busy":true,"heldBy":"desktop"}} ``` -### `amux/control_submitted` +### `rooms/control_submitted` Replay-safe intent event for accepted mux controls such as hard steer or immediate idle steer. ```json -{"jsonrpc":"2.0","method":"amux/control_submitted","params":{"roomId":"work","kind":"steer","mode":"replace_active","peerId":"phone","text":"try a shorter answer","amuxTurnId":"at-42"}} +{"jsonrpc":"2.0","method":"rooms/control_submitted","params":{"roomId":"work","kind":"steer","mode":"replace_active","peerId":"phone","text":"try a shorter answer","roomsTurnId":"at-42"}} ``` ### Queue lifecycle -`amux/queue_prompt` creates queue state owned by the mux. Queue state is visible through lifecycle notifications: +`rooms/queue_prompt` creates queue state owned by the mux. Queue state is visible through lifecycle notifications: | Method | Meaning | |---|---| -| `amux/queue_item_added` | A pending item was accepted. | -| `amux/queue_item_submitted` | A pending item became an actual `session/prompt`. | -| `amux/queue_item_completed` | The submitted queued turn settled. | -| `amux/queue_item_removed` | A still-pending item was removed via `amux/unqueue_prompt`. | -| `amux/queue_item_orphaned` | The submitting peer detached before the item was submitted; the item remains in queue but no longer has a live owner. | +| `rooms/queue_item_added` | A pending item was accepted. | +| `rooms/queue_item_submitted` | A pending item became an actual `session/prompt`. | +| `rooms/queue_item_completed` | The submitted queued turn settled. | +| `rooms/queue_item_removed` | A still-pending item was removed via `rooms/unqueue_prompt`. | +| `rooms/queue_item_orphaned` | The submitting peer detached before the item was submitted; the item remains in queue but no longer has a live owner. | Representative shape: ```json -{"jsonrpc":"2.0","method":"amux/queue_item_added","params":{"roomId":"work","queueItemId":"aq-3","peerId":"phone","text":"next, write tests"}} +{"jsonrpc":"2.0","method":"rooms/queue_item_added","params":{"roomId":"work","queueItemId":"aq-3","peerId":"phone","text":"next, write tests"}} ``` ### Agent-request lifecycle @@ -243,19 +243,19 @@ Agent-initiated requests such as `session/request_permission` are live actionabl | Method | Meaning | |---|---| -| `amux/agent_request_opened` | Replay-safe context for an agent-initiated request. | -| `amux/agent_request_resolved` | The request was consumed by a peer reply, agent cancellation, or mux turn-end cleanup. | +| `rooms/agent_request_opened` | Replay-safe context for an agent-initiated request. | +| `rooms/agent_request_resolved` | The request was consumed by a peer reply, agent cancellation, or mux turn-end cleanup. | ```jsonc { "jsonrpc": "2.0", - "method": "amux/agent_request_opened", + "method": "rooms/agent_request_opened", "params": { "roomId": "work", "requestId": 99, "requestMethod": "session/request_permission", "requestParams": { /* original params */ }, - "amuxTurnId": "at-42" + "roomsTurnId": "at-42" } } ``` @@ -263,7 +263,7 @@ Agent-initiated requests such as `session/request_permission` are live actionabl ```jsonc { "jsonrpc": "2.0", - "method": "amux/agent_request_resolved", + "method": "rooms/agent_request_resolved", "params": { "roomId": "work", "requestId": 99, @@ -286,27 +286,27 @@ Unresolved permission requests are stored separately and re-issued after `sessio When attach history uses streamed delivery, the stream is bracketed with replay markers. ```json -{"jsonrpc":"2.0","method":"amux/replay_started","params":{"roomId":"work","phase":"attach_history","replayOrder":"chronological","generation":3,"replayBoundarySeq":120,"frameCount":42}} +{"jsonrpc":"2.0","method":"rooms/replay_started","params":{"roomId":"work","phase":"attach_history","replayOrder":"chronological","generation":3,"replayBoundarySeq":120,"frameCount":42}} ``` ```json -{"jsonrpc":"2.0","method":"amux/replay_complete","params":{"roomId":"work","phase":"attach_history","replayOrder":"chronological","generation":3,"replayBoundarySeq":120,"frameCount":42}} +{"jsonrpc":"2.0","method":"rooms/replay_complete","params":{"roomId":"work","phase":"attach_history","replayOrder":"chronological","generation":3,"replayBoundarySeq":120,"frameCount":42}} ``` ### Segment lifecycle Segments describe ACP session-id lineage inside one mux room. -`amux/segment_started` opens a segment: +`rooms/segment_started` opens a segment: ```json -{"jsonrpc":"2.0","method":"amux/segment_started","params":{"roomId":"work","segmentId":"seg-2","acpSessionId":"sess-child","openedAt":"2026-05-27T19:00:00Z"}} +{"jsonrpc":"2.0","method":"rooms/segment_started","params":{"roomId":"work","segmentId":"seg-2","acpSessionId":"sess-child","openedAt":"2026-05-27T19:00:00Z"}} ``` -`amux/segment_ended` closes one: +`rooms/segment_ended` closes one: ```json -{"jsonrpc":"2.0","method":"amux/segment_ended","params":{"roomId":"work","segmentId":"seg-1","closedAt":"2026-05-27T19:00:00Z","endReason":"session_load","successorSegmentId":"seg-2"}} +{"jsonrpc":"2.0","method":"rooms/segment_ended","params":{"roomId":"work","segmentId":"seg-1","closedAt":"2026-05-27T19:00:00Z","endReason":"session_load","successorSegmentId":"seg-2"}} ``` Supported `endReason` values: @@ -314,54 +314,56 @@ Supported `endReason` values: - `session_load` — client called `session/load` and the canonical ACP session id changed. - `acp_session_id_changed` — the agent emitted a notification with a different `params.sessionId` than the active segment. +`endReason` is best-effort diagnostic metadata, not a strict priority contract: when both signals coincide (e.g. an agent emits loaded-session `session/update`s before its `session/load` response), the segment is labeled by whichever signal the mux observes first, which can be `acp_session_id_changed` even for a load-initiated rotation. + Provider-specific metadata is never a segment-rotation signal. If an agent emits provider metadata, it remains opaque payload data for clients that care. ## Subscriber control requests These requests are addressed to the mux, not the agent. -### `amux/cancel_active_turn` +### `rooms/cancel_active_turn` -Any peer can cancel the active turn. The mux broadcasts `amux/turn_cancelled`, sends ACP-native `session/cancel { sessionId }` to the agent, and waits for normal turn settlement. +Any peer can cancel the active turn. The mux broadcasts `rooms/turn_cancelled`, sends ACP-native `session/cancel { sessionId }` to the agent, and waits for normal turn settlement. ```json -{"jsonrpc":"2.0","id":10,"method":"amux/cancel_active_turn","params":{"reason":"stop"}} +{"jsonrpc":"2.0","id":10,"method":"rooms/cancel_active_turn","params":{"reason":"stop"}} ``` -### `amux/steer_active_turn` +### `rooms/steer_active_turn` When a turn is active, hard steer cancels/supersedes it and starts a replacement prompt after settlement. When idle, the steer text is submitted immediately as the next prompt. ```json -{"jsonrpc":"2.0","id":11,"method":"amux/steer_active_turn","params":{"text":"make it concise"}} +{"jsonrpc":"2.0","id":11,"method":"rooms/steer_active_turn","params":{"text":"make it concise"}} ``` A second hard steer while one is pending is rejected with JSON-RPC `-32002`. -### `amux/queue_prompt` +### `rooms/queue_prompt` Queues text behind the active turn, or submits it immediately if idle. The queue is capped at six pending items; full queue returns JSON-RPC `-32003`. ```json -{"jsonrpc":"2.0","id":12,"method":"amux/queue_prompt","params":{"text":"after that, add tests"}} +{"jsonrpc":"2.0","id":12,"method":"rooms/queue_prompt","params":{"text":"after that, add tests"}} ``` -### `amux/unqueue_prompt` +### `rooms/unqueue_prompt` Removes a still-pending queued item. ```json -{"jsonrpc":"2.0","id":13,"method":"amux/unqueue_prompt","params":{"queueItemId":"aq-3"}} +{"jsonrpc":"2.0","id":13,"method":"rooms/unqueue_prompt","params":{"queueItemId":"aq-3"}} ``` ## Replay metadata -Broadcast-tier frames may gain mux metadata under `params._meta.amux` when replayed or persisted: +Broadcast-tier frames may gain mux metadata under `params._meta.rooms` when replayed or persisted: ```jsonc { "_meta": { - "amux": { + "rooms": { "recordedAt": "2026-05-27T19:00:00.000Z", "replaySeq": 42, "segmentId": "seg-2" @@ -376,10 +378,10 @@ This metadata describes mux recording/replay, not agent semantics. Live agent pa Clients SHOULD: -1. Treat ACP frames as agent conversation and `amux/*` frames as room/control metadata. +1. Treat ACP frames as agent conversation and `rooms/*` frames as room/control metadata. 2. Use `roomId` for mux state and `sessionId` only for upstream ACP payloads. -3. Use `amuxTurnId` to bracket turns across streamed agent chunks. +3. Use `roomsTurnId` to bracket turns across streamed agent chunks. 4. Prefer `session/attach` with `replay=skip` for reconnect/bootstrap. 5. Request `historyPolicy: "full_lineage"` when rendering a full room transcript across segment rotations. -6. Treat replayed `amux/agent_request_opened` as inert context; only live or re-issued raw `session/request_permission` frames are actionable. -7. Tolerate unknown `amux/*` methods and unknown fields. +6. Treat replayed `rooms/agent_request_opened` as inert context; only live or re-issued raw `session/request_permission` frames are actionable. +7. Tolerate unknown `rooms/*` methods and unknown fields. diff --git a/docs/design/rooms.md b/docs/design/rooms.md index 6ff1bad..df0417e 100644 --- a/docs/design/rooms.md +++ b/docs/design/rooms.md @@ -1,7 +1,7 @@ # Rooms This document specifies the v0.2 **rooms** abstraction. It complements -`docs/design/amux-namespace.md`, which spells out the `amux/*` namespace +`docs/design/rooms-namespace.md`, which spells out the `rooms/*` namespace itself, and assumes familiarity with the per-session actor model. ## Why @@ -41,29 +41,29 @@ machine; it remains opaque payload data for clients that understand it. 2. `replaySeq` is one global monotonic counter spanning all segments. Per-segment slicing is recoverable via `Segment.opened_replay_seq` / `Segment.closed_replay_seq`. -3. Segment rotation emits exactly two frames in order: `amux/segment_ended` - for the closing segment, then `amux/segment_started` for the opening - one. The first segment of a room emits only `amux/segment_started`. +3. Segment rotation emits exactly two frames in order: `rooms/segment_ended` + for the closing segment, then `rooms/segment_started` for the opening + one. The first segment of a room emits only `rooms/segment_started`. 4. Mid-turn rotation does not tear down the active turn. The rotation - frames interleave between agent chunks. `amux/turn_complete` fires + frames interleave between agent chunks. `rooms/turn_complete` fires normally when the agent finishes. Clients render this as one turn that crossed a segment boundary. 5. Frames recorded before any canonical session id arrives carry `SegmentId(0)` (a sentinel). They surface in current-segment replay - alongside the active segment so the bootstrap context (`amux/peer_joined` + alongside the active segment so the bootstrap context (`rooms/peer_joined` for early subscribers) is preserved. ## Wire shapes -### `amux/segment_started` +### `rooms/segment_started` Emitted once on initial open and once per rotation. The first segment of a -room emits this alone (no `amux/segment_ended` precedes it). +room emits this alone (no `rooms/segment_ended` precedes it). ```jsonc { "jsonrpc": "2.0", - "method": "amux/segment_started", + "method": "rooms/segment_started", "params": { "roomId": "", "segmentId": "seg-3", @@ -73,7 +73,7 @@ room emits this alone (no `amux/segment_ended` precedes it). } ``` -### `amux/segment_ended` +### `rooms/segment_ended` Emitted as the closing bookend on rotation. Carries the closing segment id; `successorSegmentId` points at the opening one so clients can pair the @@ -82,7 +82,7 @@ bookends without state-tracking. ```jsonc { "jsonrpc": "2.0", - "method": "amux/segment_ended", + "method": "rooms/segment_ended", "params": { "roomId": "", "segmentId": "seg-2", @@ -101,10 +101,10 @@ bookends without state-tracking. different ACP `sessionId` than the active segment carries. Both bookend frames flow through the existing `broadcast()` path so they -pick up the same `_meta.amux { recordedAt, replaySeq }` envelope every -other frame uses. The transcript records `amux/segment_ended` while the +pick up the same `_meta.rooms { recordedAt, replaySeq }` envelope every +other frame uses. The transcript records `rooms/segment_ended` while the closing segment is still active (it lands in the closing segment); the mux -then rotates `active_segment_id` and broadcasts `amux/segment_started` +then rotates `active_segment_id` and broadcasts `rooms/segment_started` (which lands in the opening segment). Late joiners replaying frames by `segment_id` can slice the transcript cleanly. @@ -122,8 +122,8 @@ clients that understand it. - `full` (default) — frames from the active segment only, plus any pre-segment bootstrap frames tagged with `SegmentId(0)`, plus any - `amux/turn_started` / `amux/turn_complete` / `amux/turn_cancelled` - bookend from a prior segment whose `amuxTurnId` brackets at least one + `rooms/turn_started` / `rooms/turn_complete` / `rooms/turn_cancelled` + bookend from a prior segment whose `roomsTurnId` brackets at least one frame in the active segment or matches the currently active turn. The lifecycle-frame carry exists so a mid-turn segment rotation doesn't leave clients staring at an unmatched `turn_complete`. Agent chunks @@ -148,7 +148,7 @@ clients can see that earlier segments exist: "result": { // … "_meta": { - "amux": { + "rooms": { "snapshot": { // … "activeSegmentId": "seg-3", @@ -166,19 +166,19 @@ clients can see that earlier segments exist: ## Mid-turn rotation -The active turn is keyed by `active_turn_mux_id` and `amuxTurnId`, neither +The active turn is keyed by `active_turn_mux_id` and `roomsTurnId`, neither of which is tied to a segment. When `detect_segment_signal_*` fires mid-turn the rotation frames interleave between agent chunks and -`amux/turn_complete` fires normally on the agent's natural settlement. +`rooms/turn_complete` fires normally on the agent's natural settlement. Transcript ordering inside that turn is: ``` -amux/turn_started (old segment) +rooms/turn_started (old segment) agent chunks (old segment) -amux/segment_ended (old segment) -amux/segment_started (new segment) +rooms/segment_ended (old segment) +rooms/segment_started (new segment) agent chunks (new segment) -amux/turn_complete (new segment) +rooms/turn_complete (new segment) ``` Clients render this as one turn that crossed a segment. The `Turn` @@ -193,26 +193,49 @@ prompts are retargeted to the new ACP id. The room transcript is in-memory by default. With `--replay-store `, broadcast-tier frames are also appended to one JSONL file per room. On -restart, the mux rehydrates replay frames and segment bookends so late -joiners can recover visible room history. +restart, the mux rehydrates those persisted broadcast frames so late joiners +can recover the transcript via `historyPolicy: full_lineage`. -Persistence still has a narrow scope: +Persistence has a narrow scope: - It persists mux broadcast history, not the upstream agent's internal conversation state. - It does not persist in-flight agent requests or unresolved permissions. -- It depends on segment bookends for canonical session-id restoration; keep - `--emit-segment-frames=true` if restart replay should preserve lineage. - The current store is append-only and unbounded. Bounded eviction remains a follow-up. +### Known limitation: cross-restart segment fidelity + +Restart rehydrates the broadcast *frames* but not the *segment lineage* layered +on top of them. The rooms layer comes back with no `segments`, no +`activeSegmentId`, and a reset `replayGeneration`, and the core canonical +session id is not restored. So after a restart only `historyPolicy: +full_lineage` (the whole transcript) is correct; the segment-aware views — +current-segment `full`, the attach `snapshot` lineage, `replayGeneration`, and +the resolved `sessionId` — are wrong until new agent activity re-establishes +them. + +This bites long-lived rooms that span multiple segments across a restart. +Example: a team keeps a shared coding room open for days, during which the +agent compacts / `session/load`s several times (many segments), and the mux +host is redeployed nightly. When a teammate's client reconnects the next +morning and asks for `historyPolicy: full` — "just the current segment, not the +whole multi-day lineage" — it gets a near-empty `full` view and a stale session +id, forcing every client to fall back to replaying the entire `full_lineage` +history to see the current conversation. Until reconstruction lands, use +`full_lineage` for cross-restart recovery (and `--emit-segment-frames=true`, the +default, so the segment bookends are at least persisted for a future rebuild). + +Reconstructing segment state and the canonical session id from the persisted +`rooms/segment_*` frames on restart is a tracked follow-up. + The natural future seam is a SQLite layer keyed on `(room_id, segment_id, replay_seq)` if JSONL stops being enough. ## Feature flag `--emit-segment-frames` (default `true`) gates emission of -`amux/segment_started` and `amux/segment_ended`. The internal state +`rooms/segment_started` and `rooms/segment_ended`. The internal state machine rotates regardless; the flag exists only to preserve byte-equivalence with v0.1.x for clients that haven't picked up the new frame methods yet. diff --git a/docs/examples/client-contract/README.md b/docs/examples/client-contract/README.md index 0d3c9a9..db1bb7a 100644 --- a/docs/examples/client-contract/README.md +++ b/docs/examples/client-contract/README.md @@ -2,13 +2,13 @@ Copyable JSON fixtures for clients that talk to `acp-mux`. -These are **contract examples**, not a mock-agent transcript. They use a real-agent-oriented naming convention (`sess-claude-1`, `claude-desktop`) so examples line up with the README's Claude Agent ACP quickstart, but the `amux/*` shapes are provider-neutral. +These are **contract examples**, not a mock-agent transcript. They use a real-agent-oriented naming convention (`sess-claude-1`, `claude-desktop`) so examples line up with the README's Claude Agent ACP quickstart, but the `rooms/*` shapes are provider-neutral. ## Layout -- `requests/` — frames a client can send to `amux`. +- `requests/` — frames a client can send to `rooms`. - `responses/` — representative mux-owned responses. -- `notifications/` — mux-owned `amux/*` notifications clients should handle. +- `notifications/` — mux-owned `rooms/*` notifications clients should handle. - `sequences/` — JSONL frame sequences for UI/rendering tests. ## Notes @@ -16,4 +16,4 @@ These are **contract examples**, not a mock-agent transcript. They use a real-ag - `roomId` is the mux collaboration container. - `sessionId` / `acpSessionId` is the upstream ACP agent's id. - Provider metadata, if present, is payload data and is not represented in these mux-owned fixtures. -- Treat `amux/agent_request_opened` as replay-safe context. Only raw live or re-issued ACP `session/request_permission` requests are actionable. +- Treat `rooms/agent_request_opened` as replay-safe context. Only raw live or re-issued ACP `session/request_permission` requests are actionable. diff --git a/docs/examples/client-contract/notifications/amux-agent-request-opened.notification.json b/docs/examples/client-contract/notifications/rooms-agent-request-opened.notification.json similarity index 86% rename from docs/examples/client-contract/notifications/amux-agent-request-opened.notification.json rename to docs/examples/client-contract/notifications/rooms-agent-request-opened.notification.json index 2cd6e2f..4a62038 100644 --- a/docs/examples/client-contract/notifications/amux-agent-request-opened.notification.json +++ b/docs/examples/client-contract/notifications/rooms-agent-request-opened.notification.json @@ -1,6 +1,6 @@ { "jsonrpc": "2.0", - "method": "amux/agent_request_opened", + "method": "rooms/agent_request_opened", "params": { "roomId": "work", "requestId": 99, @@ -19,6 +19,6 @@ } ] }, - "amuxTurnId": "at-1" + "roomsTurnId": "at-1" } } diff --git a/docs/examples/client-contract/notifications/amux-agent-request-resolved.notification.json b/docs/examples/client-contract/notifications/rooms-agent-request-resolved.notification.json similarity index 83% rename from docs/examples/client-contract/notifications/amux-agent-request-resolved.notification.json rename to docs/examples/client-contract/notifications/rooms-agent-request-resolved.notification.json index 26943d0..147a9bf 100644 --- a/docs/examples/client-contract/notifications/amux-agent-request-resolved.notification.json +++ b/docs/examples/client-contract/notifications/rooms-agent-request-resolved.notification.json @@ -1,6 +1,6 @@ { "jsonrpc": "2.0", - "method": "amux/agent_request_resolved", + "method": "rooms/agent_request_resolved", "params": { "roomId": "work", "requestId": 99, diff --git a/docs/examples/client-contract/notifications/amux-peer-joined.notification.json b/docs/examples/client-contract/notifications/rooms-peer-joined.notification.json similarity index 80% rename from docs/examples/client-contract/notifications/amux-peer-joined.notification.json rename to docs/examples/client-contract/notifications/rooms-peer-joined.notification.json index 4b9eca7..3d2df91 100644 --- a/docs/examples/client-contract/notifications/amux-peer-joined.notification.json +++ b/docs/examples/client-contract/notifications/rooms-peer-joined.notification.json @@ -1,6 +1,6 @@ { "jsonrpc": "2.0", - "method": "amux/peer_joined", + "method": "rooms/peer_joined", "params": { "roomId": "work", "peerId": "phone", diff --git a/docs/examples/client-contract/notifications/amux-peer-left.notification.json b/docs/examples/client-contract/notifications/rooms-peer-left.notification.json similarity index 73% rename from docs/examples/client-contract/notifications/amux-peer-left.notification.json rename to docs/examples/client-contract/notifications/rooms-peer-left.notification.json index 2713cf3..3efaff6 100644 --- a/docs/examples/client-contract/notifications/amux-peer-left.notification.json +++ b/docs/examples/client-contract/notifications/rooms-peer-left.notification.json @@ -1,6 +1,6 @@ { "jsonrpc": "2.0", - "method": "amux/peer_left", + "method": "rooms/peer_left", "params": { "roomId": "work", "peerId": "phone" diff --git a/docs/examples/client-contract/notifications/amux-queue-item-added.notification.json b/docs/examples/client-contract/notifications/rooms-queue-item-added.notification.json similarity index 85% rename from docs/examples/client-contract/notifications/amux-queue-item-added.notification.json rename to docs/examples/client-contract/notifications/rooms-queue-item-added.notification.json index e38ac39..025324b 100644 --- a/docs/examples/client-contract/notifications/amux-queue-item-added.notification.json +++ b/docs/examples/client-contract/notifications/rooms-queue-item-added.notification.json @@ -1,6 +1,6 @@ { "jsonrpc": "2.0", - "method": "amux/queue_item_added", + "method": "rooms/queue_item_added", "params": { "roomId": "work", "queueItemId": "aq-1", diff --git a/docs/examples/client-contract/notifications/amux-queue-item-completed.notification.json b/docs/examples/client-contract/notifications/rooms-queue-item-completed.notification.json similarity index 63% rename from docs/examples/client-contract/notifications/amux-queue-item-completed.notification.json rename to docs/examples/client-contract/notifications/rooms-queue-item-completed.notification.json index 602991d..30270d5 100644 --- a/docs/examples/client-contract/notifications/amux-queue-item-completed.notification.json +++ b/docs/examples/client-contract/notifications/rooms-queue-item-completed.notification.json @@ -1,10 +1,10 @@ { "jsonrpc": "2.0", - "method": "amux/queue_item_completed", + "method": "rooms/queue_item_completed", "params": { "roomId": "work", "queueItemId": "aq-1", - "amuxTurnId": "at-2", + "roomsTurnId": "at-2", "stopReason": "end_turn" } } diff --git a/docs/examples/client-contract/notifications/amux-queue-item-submitted.notification.json b/docs/examples/client-contract/notifications/rooms-queue-item-submitted.notification.json similarity index 57% rename from docs/examples/client-contract/notifications/amux-queue-item-submitted.notification.json rename to docs/examples/client-contract/notifications/rooms-queue-item-submitted.notification.json index 9484cdd..6fb70ca 100644 --- a/docs/examples/client-contract/notifications/amux-queue-item-submitted.notification.json +++ b/docs/examples/client-contract/notifications/rooms-queue-item-submitted.notification.json @@ -1,9 +1,9 @@ { "jsonrpc": "2.0", - "method": "amux/queue_item_submitted", + "method": "rooms/queue_item_submitted", "params": { "roomId": "work", "queueItemId": "aq-1", - "amuxTurnId": "at-2" + "roomsTurnId": "at-2" } } diff --git a/docs/examples/client-contract/notifications/amux-replay-started.notification.json b/docs/examples/client-contract/notifications/rooms-replay-complete.notification.json similarity index 84% rename from docs/examples/client-contract/notifications/amux-replay-started.notification.json rename to docs/examples/client-contract/notifications/rooms-replay-complete.notification.json index 61ebd5f..d20f994 100644 --- a/docs/examples/client-contract/notifications/amux-replay-started.notification.json +++ b/docs/examples/client-contract/notifications/rooms-replay-complete.notification.json @@ -1,6 +1,6 @@ { "jsonrpc": "2.0", - "method": "amux/replay_started", + "method": "rooms/replay_complete", "params": { "roomId": "work", "phase": "attach_history", diff --git a/docs/examples/client-contract/notifications/amux-replay-complete.notification.json b/docs/examples/client-contract/notifications/rooms-replay-started.notification.json similarity index 84% rename from docs/examples/client-contract/notifications/amux-replay-complete.notification.json rename to docs/examples/client-contract/notifications/rooms-replay-started.notification.json index f88ee97..e6ad08a 100644 --- a/docs/examples/client-contract/notifications/amux-replay-complete.notification.json +++ b/docs/examples/client-contract/notifications/rooms-replay-started.notification.json @@ -1,6 +1,6 @@ { "jsonrpc": "2.0", - "method": "amux/replay_complete", + "method": "rooms/replay_started", "params": { "roomId": "work", "phase": "attach_history", diff --git a/docs/examples/client-contract/notifications/amux-segment-ended.notification.json b/docs/examples/client-contract/notifications/rooms-segment-ended.notification.json similarity index 84% rename from docs/examples/client-contract/notifications/amux-segment-ended.notification.json rename to docs/examples/client-contract/notifications/rooms-segment-ended.notification.json index 8811328..f4f9821 100644 --- a/docs/examples/client-contract/notifications/amux-segment-ended.notification.json +++ b/docs/examples/client-contract/notifications/rooms-segment-ended.notification.json @@ -1,6 +1,6 @@ { "jsonrpc": "2.0", - "method": "amux/segment_ended", + "method": "rooms/segment_ended", "params": { "roomId": "work", "segmentId": "seg-1", diff --git a/docs/examples/client-contract/notifications/amux-segment-started.notification.json b/docs/examples/client-contract/notifications/rooms-segment-started.notification.json similarity index 81% rename from docs/examples/client-contract/notifications/amux-segment-started.notification.json rename to docs/examples/client-contract/notifications/rooms-segment-started.notification.json index 0135b6a..e9743fc 100644 --- a/docs/examples/client-contract/notifications/amux-segment-started.notification.json +++ b/docs/examples/client-contract/notifications/rooms-segment-started.notification.json @@ -1,6 +1,6 @@ { "jsonrpc": "2.0", - "method": "amux/segment_started", + "method": "rooms/segment_started", "params": { "roomId": "work", "segmentId": "seg-2", diff --git a/docs/examples/client-contract/notifications/amux-session-busy.notification.json b/docs/examples/client-contract/notifications/rooms-session-busy.notification.json similarity index 75% rename from docs/examples/client-contract/notifications/amux-session-busy.notification.json rename to docs/examples/client-contract/notifications/rooms-session-busy.notification.json index 17c7c88..173b3b6 100644 --- a/docs/examples/client-contract/notifications/amux-session-busy.notification.json +++ b/docs/examples/client-contract/notifications/rooms-session-busy.notification.json @@ -1,6 +1,6 @@ { "jsonrpc": "2.0", - "method": "amux/session_busy", + "method": "rooms/session_busy", "params": { "roomId": "work", "busy": true, diff --git a/docs/examples/client-contract/notifications/amux-session-context.notification.json b/docs/examples/client-contract/notifications/rooms-session-context.notification.json similarity index 71% rename from docs/examples/client-contract/notifications/amux-session-context.notification.json rename to docs/examples/client-contract/notifications/rooms-session-context.notification.json index 7129fb5..6b0dbf3 100644 --- a/docs/examples/client-contract/notifications/amux-session-context.notification.json +++ b/docs/examples/client-contract/notifications/rooms-session-context.notification.json @@ -1,6 +1,6 @@ { "jsonrpc": "2.0", - "method": "amux/session_context", + "method": "rooms/session_context", "params": { "roomId": "work", "cwd": "/home/you/project" diff --git a/docs/examples/client-contract/notifications/amux-turn-cancelled.notification.json b/docs/examples/client-contract/notifications/rooms-turn-cancelled.notification.json similarity index 71% rename from docs/examples/client-contract/notifications/amux-turn-cancelled.notification.json rename to docs/examples/client-contract/notifications/rooms-turn-cancelled.notification.json index 9508d12..1191c8f 100644 --- a/docs/examples/client-contract/notifications/amux-turn-cancelled.notification.json +++ b/docs/examples/client-contract/notifications/rooms-turn-cancelled.notification.json @@ -1,9 +1,9 @@ { "jsonrpc": "2.0", - "method": "amux/turn_cancelled", + "method": "rooms/turn_cancelled", "params": { "roomId": "work", - "amuxTurnId": "at-1", + "roomsTurnId": "at-1", "cancelledBy": "phone", "originalDriver": "desktop", "reason": "user requested stop" diff --git a/docs/examples/client-contract/notifications/amux-turn-complete.notification.json b/docs/examples/client-contract/notifications/rooms-turn-complete.notification.json similarity index 60% rename from docs/examples/client-contract/notifications/amux-turn-complete.notification.json rename to docs/examples/client-contract/notifications/rooms-turn-complete.notification.json index 11d2175..3d31927 100644 --- a/docs/examples/client-contract/notifications/amux-turn-complete.notification.json +++ b/docs/examples/client-contract/notifications/rooms-turn-complete.notification.json @@ -1,9 +1,9 @@ { "jsonrpc": "2.0", - "method": "amux/turn_complete", + "method": "rooms/turn_complete", "params": { "roomId": "work", - "amuxTurnId": "at-1", + "roomsTurnId": "at-1", "stopReason": "end_turn" } } diff --git a/docs/examples/client-contract/notifications/amux-turn-started.notification.json b/docs/examples/client-contract/notifications/rooms-turn-started.notification.json similarity index 81% rename from docs/examples/client-contract/notifications/amux-turn-started.notification.json rename to docs/examples/client-contract/notifications/rooms-turn-started.notification.json index a5a76fb..2aacd56 100644 --- a/docs/examples/client-contract/notifications/amux-turn-started.notification.json +++ b/docs/examples/client-contract/notifications/rooms-turn-started.notification.json @@ -1,9 +1,9 @@ { "jsonrpc": "2.0", - "method": "amux/turn_started", + "method": "rooms/turn_started", "params": { "roomId": "work", - "amuxTurnId": "at-1", + "roomsTurnId": "at-1", "peerId": "desktop", "peerName": "Desktop", "role": "primary", diff --git a/docs/examples/client-contract/requests/amux-cancel-active-turn.request.json b/docs/examples/client-contract/requests/rooms-cancel-active-turn.request.json similarity index 69% rename from docs/examples/client-contract/requests/amux-cancel-active-turn.request.json rename to docs/examples/client-contract/requests/rooms-cancel-active-turn.request.json index b944280..1a8e3f8 100644 --- a/docs/examples/client-contract/requests/amux-cancel-active-turn.request.json +++ b/docs/examples/client-contract/requests/rooms-cancel-active-turn.request.json @@ -1,7 +1,7 @@ { "jsonrpc": "2.0", "id": 21, - "method": "amux/cancel_active_turn", + "method": "rooms/cancel_active_turn", "params": { "reason": "user requested stop" } diff --git a/docs/examples/client-contract/requests/amux-queue-prompt.request.json b/docs/examples/client-contract/requests/rooms-queue-prompt.request.json similarity index 76% rename from docs/examples/client-contract/requests/amux-queue-prompt.request.json rename to docs/examples/client-contract/requests/rooms-queue-prompt.request.json index 78df5e2..d77aab2 100644 --- a/docs/examples/client-contract/requests/amux-queue-prompt.request.json +++ b/docs/examples/client-contract/requests/rooms-queue-prompt.request.json @@ -1,7 +1,7 @@ { "jsonrpc": "2.0", "id": 20, - "method": "amux/queue_prompt", + "method": "rooms/queue_prompt", "params": { "text": "After this, propose the smallest next patch." } diff --git a/docs/examples/client-contract/requests/session-attach-full-lineage.request.json b/docs/examples/client-contract/requests/session-attach-full-lineage.request.json index 98e8b57..d12e16f 100644 --- a/docs/examples/client-contract/requests/session-attach-full-lineage.request.json +++ b/docs/examples/client-contract/requests/session-attach-full-lineage.request.json @@ -6,7 +6,7 @@ "sessionId": "sess-claude-1", "historyPolicy": "full_lineage", "_meta": { - "amux": { + "rooms": { "replayOrder": "chronological", "historyDelivery": "response" } diff --git a/docs/examples/client-contract/requests/session-attach-stream-newest-first.request.json b/docs/examples/client-contract/requests/session-attach-stream-newest-first.request.json index d2a2ac6..948b223 100644 --- a/docs/examples/client-contract/requests/session-attach-stream-newest-first.request.json +++ b/docs/examples/client-contract/requests/session-attach-stream-newest-first.request.json @@ -6,7 +6,7 @@ "sessionId": "sess-claude-1", "historyPolicy": "full_lineage", "_meta": { - "amux": { + "rooms": { "replayOrder": "newest_turn_first", "historyDelivery": "stream" } diff --git a/docs/examples/client-contract/responses/session-attach-full-lineage.response.json b/docs/examples/client-contract/responses/session-attach-full-lineage.response.json index d073a03..c4e5c24 100644 --- a/docs/examples/client-contract/responses/session-attach-full-lineage.response.json +++ b/docs/examples/client-contract/responses/session-attach-full-lineage.response.json @@ -7,10 +7,10 @@ "historyPolicy": "full_lineage", "history": [ { - "method": "amux/turn_started", + "method": "rooms/turn_started", "params": { "roomId": "work", - "amuxTurnId": "at-1", + "roomsTurnId": "at-1", "peerId": "desktop", "content": [ { @@ -21,40 +21,42 @@ } }, { - "method": "amux/turn_complete", + "method": "rooms/turn_complete", "params": { "roomId": "work", - "amuxTurnId": "at-1", + "roomsTurnId": "at-1", "stopReason": "end_turn" } } ], "_meta": { - "amux": { + "rooms": { "connectedClients": [ - { - "peerId": "desktop", - "peerName": "Desktop", - "role": "primary" - }, - { - "peerId": "phone", - "peerName": "Phone", - "role": "mobile" - } + { "clientId": "desktop", "name": "Desktop" }, + { "clientId": "phone", "name": "Phone" } ], "appliedReplayOrder": "chronological", "appliedHistoryDelivery": "response", "snapshot": { - "roomId": "work", - "activeSegmentId": "seg-1", + "connectedClients": [ + { "clientId": "desktop", "name": "Desktop" }, + { "clientId": "phone", "name": "Phone" } + ], + "selfPeer": { "clientId": "phone", "name": "Phone" }, + "activeTurn": null, + "queue": [], + "pendingPermissions": [], + "replayBoundarySeq": 3, + "replayGeneration": 0, "segments": [ { "id": "seg-1", "acpSessionId": "sess-claude-1", - "openedAt": "2026-05-31T12:00:00Z" + "openedAt": "2026-05-31T12:00:00Z", + "openedReplaySeq": 0 } - ] + ], + "activeSegmentId": "seg-1" } } } diff --git a/docs/examples/client-contract/sequences/basic-turn.jsonl b/docs/examples/client-contract/sequences/basic-turn.jsonl index b1b3e26..74bdd47 100644 --- a/docs/examples/client-contract/sequences/basic-turn.jsonl +++ b/docs/examples/client-contract/sequences/basic-turn.jsonl @@ -1,5 +1,5 @@ -{"jsonrpc": "2.0", "method": "amux/session_context", "params": {"roomId": "work", "cwd": "/home/you/project"}} -{"jsonrpc": "2.0", "method": "amux/peer_joined", "params": {"roomId": "work", "peerId": "phone", "peerName": "Phone", "role": "mobile"}} -{"jsonrpc": "2.0", "method": "amux/turn_started", "params": {"roomId": "work", "amuxTurnId": "at-1", "peerId": "desktop", "peerName": "Desktop", "role": "primary", "content": [{"type": "text", "text": "Summarize this repository in three bullets."}]}} +{"jsonrpc": "2.0", "method": "rooms/session_context", "params": {"roomId": "work", "cwd": "/home/you/project"}} +{"jsonrpc": "2.0", "method": "rooms/peer_joined", "params": {"roomId": "work", "peerId": "phone", "peerName": "Phone", "role": "mobile"}} +{"jsonrpc": "2.0", "method": "rooms/turn_started", "params": {"roomId": "work", "roomsTurnId": "at-1", "peerId": "desktop", "peerName": "Desktop", "role": "primary", "content": [{"type": "text", "text": "Summarize this repository in three bullets."}]}} {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "sess-claude-1", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "This repository..."}}}} -{"jsonrpc": "2.0", "method": "amux/turn_complete", "params": {"roomId": "work", "amuxTurnId": "at-1", "stopReason": "end_turn"}} +{"jsonrpc": "2.0", "method": "rooms/turn_complete", "params": {"roomId": "work", "roomsTurnId": "at-1", "stopReason": "end_turn"}} diff --git a/src/multiplex/mod.rs b/src/multiplex/mod.rs deleted file mode 100644 index de98ac2..0000000 --- a/src/multiplex/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod subscriber; diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs deleted file mode 100644 index 09a5a9a..0000000 --- a/src/protocol/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod amux; -pub mod attach; -pub mod jsonrpc; diff --git a/src/room/attach.rs b/src/room/attach.rs deleted file mode 100644 index db0c3c4..0000000 --- a/src/room/attach.rs +++ /dev/null @@ -1,737 +0,0 @@ -//! RFD #533-inspired `session/attach` / `session/detach` proxy-local methods -//! and the streaming attach machinery (snapshot, latest-segment, async -//! backfill) that lives on top of the mux's broadcast replay log. -//! -//! All methods here are declared as `impl RoomInner` blocks; the parent -//! `state` module owns the struct, fields, and actor loop. This module is -//! a logical split for review and maintenance, not a behavioral one. - -use std::collections::{HashSet, VecDeque}; -use std::time::Duration; - -use bytes::Bytes; -use serde_json::Value; -use tokio::sync::mpsc; - -use crate::multiplex::subscriber::OutMsg; -use crate::protocol::amux::{self, SegmentId}; -use crate::protocol::attach::{ - self, AttachActiveTurn, AttachAmuxMeta, AttachMeta, AttachParams, AttachPendingPermission, - AttachQueueItem, AttachResult, AttachSnapshot, ConnectedClient, DetachParams, DetachResult, - HistoryDelivery, HistoryEntry, HistoryPolicy, ReplayOrder, -}; -use crate::protocol::jsonrpc::IncomingRequest; -use crate::room::state::{PRE_SEGMENT_ID, QueuedPromptKind, ReplayEntry, RoomInner, RoomMsg}; - -#[derive(Debug)] -pub struct AttachStreamBackfill { - peer_id: String, - room_id: String, - replay_order: ReplayOrder, - replay_generation: u64, - replay_boundary_seq: u64, - frame_count: usize, - segments: VecDeque>, - started: bool, -} - -impl RoomInner { - pub(super) fn handle_attach(&mut self, peer_id: &str, req: IncomingRequest) { - let params: AttachParams = req - .params - .as_ref() - .map(|v| serde_json::from_value(v.clone()).unwrap_or_default()) - .unwrap_or_default(); - - let requested_policy = params.history_policy.unwrap_or_default(); - let requested_replay_order = params - .meta - .as_ref() - .and_then(|meta| meta.amux.as_ref()) - .and_then(|amux| amux.replay_order) - .unwrap_or_default(); - let requested_history_delivery = params - .meta - .as_ref() - .and_then(|meta| meta.amux.as_ref()) - .and_then(|amux| amux.history_delivery) - .unwrap_or_default(); - let effective_policy = match requested_policy { - HistoryPolicy::AfterMessage => { - tracing::debug!( - session = %self.room_id, - %peer_id, - after_message_id = ?params.after_message_id, - "session/attach after_message requested; falling back to full until ACP message IDs are available end-to-end", - ); - HistoryPolicy::Full - } - other => other, - }; - - let resolved_session_id = self - .acp_session_id() - .map(str::to_string) - .unwrap_or_else(|| self.room_id.clone()); - if let Some(requested) = params.session_id.as_deref() - && !requested.is_empty() - && requested != resolved_session_id - && requested != self.room_id - { - self.send_error_response( - peer_id, - req.id, - attach::ATTACH_ERR_NOT_FOUND, - "session not found", - ); - return; - } - - let connected_clients: Vec = self - .subscribers - .values() - .map(|s| ConnectedClient { - client_id: s.peer_id.clone(), - name: s.peer_name.clone(), - }) - .collect(); - let applied_history_delivery = match (effective_policy, requested_history_delivery) { - (HistoryPolicy::Full, HistoryDelivery::Stream) - | (HistoryPolicy::FullLineage, HistoryDelivery::Stream) => HistoryDelivery::Stream, - _ => HistoryDelivery::Response, - }; - let stream_entries: Vec = - if applied_history_delivery == HistoryDelivery::Stream { - self.replay_entries_for_policy(effective_policy) - } else { - Vec::new() - }; - let replay_boundary_seq = stream_entries.last().map(|entry| entry.seq).unwrap_or(0); - let snapshot = if applied_history_delivery == HistoryDelivery::Stream { - Some(self.attach_snapshot(peer_id, connected_clients.clone(), replay_boundary_seq)) - } else { - None - }; - let history = if applied_history_delivery == HistoryDelivery::Stream { - None - } else { - match effective_policy { - HistoryPolicy::None => None, - HistoryPolicy::Full => Some(Self::apply_history_replay_order( - self.history_full(), - requested_replay_order, - )), - HistoryPolicy::FullLineage => Some(Self::apply_history_replay_order( - self.history_full_lineage(), - requested_replay_order, - )), - HistoryPolicy::PendingOnly => Some(Self::apply_history_replay_order( - self.history_pending_only(), - requested_replay_order, - )), - HistoryPolicy::AfterMessage => unreachable!("normalized above"), - } - }; - let result = AttachResult { - session_id: resolved_session_id, - client_id: params.client_id.unwrap_or_else(|| peer_id.to_string()), - history_policy: effective_policy, - history, - meta: AttachMeta { - amux: AttachAmuxMeta { - connected_clients, - applied_replay_order: requested_replay_order, - applied_history_delivery, - snapshot, - }, - }, - }; - let result = match serde_json::to_value(result) { - Ok(v) => v, - Err(err) => { - tracing::error!(error = %err, "failed to serialize session/attach result"); - self.send_error_response( - peer_id, - req.id, - attach::ATTACH_ERR_UNSUPPORTED, - "session/attach serialization failed", - ); - return; - } - }; - self.send_result_response(peer_id, req.id, result); - if applied_history_delivery == HistoryDelivery::Stream { - let pending_permission_frames = self - .pending_permission_frames - .iter() - .map(|(_, frame)| frame.clone()) - .collect(); - self.stream_attach_history( - peer_id, - stream_entries, - requested_replay_order, - replay_boundary_seq, - pending_permission_frames, - ); - } else { - self.reissue_pending_permissions(peer_id); - } - } - - fn attach_snapshot( - &self, - peer_id: &str, - connected_clients: Vec, - replay_boundary_seq: u64, - ) -> AttachSnapshot { - let self_peer = connected_clients - .iter() - .find(|client| client.client_id == peer_id) - .cloned() - .unwrap_or_else(|| ConnectedClient { - client_id: peer_id.to_string(), - name: self - .subscribers - .get(peer_id) - .and_then(|s| s.peer_name.clone()), - }); - let active_turn = self.active_amux_turn_id.and_then(|amux_turn_id| { - self.active_turn_mux_id - .and_then(|mux_id| self.pending.get(&mux_id)) - .map(|pending| AttachActiveTurn { - amux_turn_id: amux_turn_id.formatted(), - peer_id: pending.peer_id.clone(), - }) - }); - let queue = self - .queued_prompts - .iter() - .map(|item| AttachQueueItem { - queue_item_id: item.queue_item_id.clone(), - peer_id: item.peer_id.clone(), - kind: match &item.kind { - QueuedPromptKind::Prompt => "prompt", - QueuedPromptKind::Queue => "queue", - QueuedPromptKind::HardSteer { .. } => "hard_steer", - } - .to_string(), - status: "queued", - }) - .collect(); - AttachSnapshot { - connected_clients, - self_peer, - active_turn, - queue, - pending_permissions: self.pending_permission_summaries(), - replay_boundary_seq, - replay_generation: self.replay_generation, - segments: self - .segments - .iter() - .map(crate::room::state::segment_summary) - .collect(), - active_segment_id: self.active_segment_id, - } - } - - fn pending_permission_summaries(&self) -> Vec { - self.pending_permission_frames - .iter() - .map(|(id, frame)| { - let value: Value = serde_json::from_slice(frame).unwrap_or(Value::Null); - let params = value.get("params"); - let tool_call = params.and_then(|p| p.get("toolCall")); - AttachPendingPermission { - request_id: serde_json::to_value(id).unwrap_or(Value::Null), - tool_name: tool_call - .and_then(|t| { - t.get("title") - .or_else(|| t.get("toolName")) - .or_else(|| t.get("name")) - }) - .and_then(Value::as_str) - .map(str::to_string), - summary: tool_call - .and_then(|t| t.get("title")) - .and_then(Value::as_str) - .map(str::to_string), - } - }) - .collect() - } - - fn stream_attach_history( - &self, - peer_id: &str, - entries: Vec, - replay_order: ReplayOrder, - replay_boundary_seq: u64, - pending_permission_frames: Vec, - ) { - let Some(sub) = self.subscribers.get(peer_id) else { - return; - }; - let outbound = sub.outbound.clone(); - let room_id = self.room_id.clone(); - let replay_generation = self.replay_generation; - let replay_order_wire = Self::replay_order_wire(replay_order); - let (latest_segment, backfill_segments) = - Self::streaming_replay_segments(entries, replay_order); - - if !latest_segment.is_empty() { - let frame_count = latest_segment.len(); - if outbound - .send(OutMsg::Frame(Bytes::from(amux::replay_started( - &room_id, - "latest_segment", - replay_order_wire, - replay_generation, - replay_boundary_seq, - frame_count, - )))) - .is_err() - { - return; - } - for entry in latest_segment { - if outbound - .send(OutMsg::Frame(entry.frame_for_replay())) - .is_err() - { - return; - } - } - if outbound - .send(OutMsg::Frame(Bytes::from(amux::replay_complete( - &room_id, - "latest_segment", - replay_order_wire, - replay_generation, - replay_boundary_seq, - frame_count, - )))) - .is_err() - { - return; - } - } - - for frame in pending_permission_frames { - if outbound.send(OutMsg::Frame(frame)).is_err() { - return; - } - } - - if backfill_segments.is_empty() { - return; - } - - let frame_count: usize = backfill_segments.iter().map(Vec::len).sum(); - let plan = AttachStreamBackfill { - peer_id: peer_id.to_string(), - room_id, - replay_order, - replay_generation, - replay_boundary_seq, - frame_count, - segments: backfill_segments - .into_iter() - .map(|segment| { - segment - .into_iter() - .map(|entry| entry.frame_for_replay()) - .collect() - }) - .collect(), - started: false, - }; - Self::schedule_attach_backfill(self.self_tx.clone(), plan, Duration::from_millis(25)); - } - - pub(super) fn send_attach_backfill_page(&self, mut plan: AttachStreamBackfill) { - let Some(sub) = self.subscribers.get(&plan.peer_id) else { - return; - }; - let replay_order_wire = Self::replay_order_wire(plan.replay_order); - if !plan.started { - if sub - .outbound - .send(OutMsg::Frame(Bytes::from(amux::replay_started( - &plan.room_id, - "backfill", - replay_order_wire, - plan.replay_generation, - plan.replay_boundary_seq, - plan.frame_count, - )))) - .is_err() - { - return; - } - plan.started = true; - } - - if let Some(segment) = plan.segments.pop_front() { - for frame in segment { - if sub.outbound.send(OutMsg::Frame(frame)).is_err() { - return; - } - } - } - - if plan.segments.is_empty() { - let _ = sub - .outbound - .send(OutMsg::Frame(Bytes::from(amux::replay_complete( - &plan.room_id, - "backfill", - replay_order_wire, - plan.replay_generation, - plan.replay_boundary_seq, - plan.frame_count, - )))); - } else { - Self::schedule_attach_backfill(self.self_tx.clone(), plan, Duration::from_millis(1)); - } - } - - fn schedule_attach_backfill( - tx: mpsc::Sender, - plan: AttachStreamBackfill, - delay: Duration, - ) { - tokio::spawn(async move { - tokio::time::sleep(delay).await; - let _ = tx.send(RoomMsg::AttachStreamBackfill(plan)).await; - }); - } - - fn streaming_replay_segments( - entries: Vec, - replay_order: ReplayOrder, - ) -> (Vec, Vec>) { - let mut ambient = Vec::new(); - let mut turns: Vec> = Vec::new(); - let mut current_turn: Option> = None; - - for entry in entries { - match Self::replay_entry_method(&entry).as_deref() { - Some("amux/turn_started") => { - if let Some(turn) = current_turn.take() - && !turn.is_empty() - { - turns.push(turn); - } - current_turn = Some(vec![entry]); - } - Some("amux/turn_complete") => { - if let Some(mut turn) = current_turn.take() { - turn.push(entry); - turns.push(turn); - } else { - ambient.push(entry); - } - } - _ => { - if let Some(turn) = current_turn.as_mut() { - turn.push(entry); - } else { - ambient.push(entry); - } - } - } - } - - if let Some(turn) = current_turn - && !turn.is_empty() - { - turns.push(turn); - } - - match replay_order { - ReplayOrder::Chronological => { - let mut backfill_segments = Vec::new(); - if !ambient.is_empty() { - backfill_segments.push(ambient); - } - backfill_segments.extend(turns); - (Vec::new(), backfill_segments) - } - ReplayOrder::NewestTurnFirst => { - let latest_segment = turns.pop().unwrap_or_default(); - let mut backfill_segments: Vec> = - turns.into_iter().rev().collect(); - if !ambient.is_empty() { - backfill_segments.push(ambient); - } - (latest_segment, backfill_segments) - } - } - } - - fn replay_entry_method(entry: &ReplayEntry) -> Option { - let value: Value = serde_json::from_slice(&entry.frame).ok()?; - value.get("method")?.as_str().map(str::to_string) - } - - fn replay_order_wire(replay_order: ReplayOrder) -> &'static str { - match replay_order { - ReplayOrder::Chronological => "chronological", - ReplayOrder::NewestTurnFirst => "newest_turn_first", - } - } - - /// Current-segment-only history. Includes pre-segment bootstrap - /// frames (`SegmentId(0)`) so peer-presence emitted before the - /// canonical ACP id was captured is preserved, plus any - /// `amux/turn_started` / `amux/turn_complete` / `amux/turn_cancelled` - /// bookend from a prior segment whose `amuxTurnId` brackets a frame - /// in the active segment or matches the currently active turn — - /// otherwise mid-turn segment rotation would leave clients staring - /// at an unmatched `turn_complete` for a turn that started in the - /// prior segment. - pub(super) fn history_full(&self) -> Vec { - let Some(log) = self.replay_log.as_ref() else { - return Vec::new(); - }; - let active = self.active_segment_id; - let carry = self.cross_segment_turn_carry(); - log.iter() - .filter(|entry| Self::full_view_includes_entry(entry, active, &carry)) - .filter_map(|entry| Self::history_entry_from_frame(&entry.frame_for_replay())) - .collect() - } - - /// Every segment's frames concatenated in `replaySeq` order. The - /// `historyPolicy: full_lineage` view for clients that want to see - /// pre-rotation history. - pub(super) fn history_full_lineage(&self) -> Vec { - let Some(log) = self.replay_log.as_ref() else { - return Vec::new(); - }; - log.iter() - .filter_map(|entry| Self::history_entry_from_frame(&entry.frame_for_replay())) - .collect() - } - - /// Replay entries shaped by `historyPolicy`, used by the streaming - /// delivery path so `stream + full` honours current-segment-only - /// semantics and never leaks pre-rotation lineage to clients that - /// didn't opt in. Carries cross-segment turn bookends for the same - /// reason `history_full` does. - pub(crate) fn replay_entries_for_policy(&self, policy: HistoryPolicy) -> Vec { - let Some(log) = self.replay_log.as_ref() else { - return Vec::new(); - }; - match policy { - HistoryPolicy::FullLineage => log.iter().cloned().collect(), - HistoryPolicy::Full => { - let active = self.active_segment_id; - let carry = self.cross_segment_turn_carry(); - log.iter() - .filter(|entry| Self::full_view_includes_entry(entry, active, &carry)) - .cloned() - .collect() - } - // Other policies don't use streaming; fall back to current-segment view. - _ => { - let active = self.active_segment_id; - let carry = self.cross_segment_turn_carry(); - log.iter() - .filter(|entry| Self::full_view_includes_entry(entry, active, &carry)) - .cloned() - .collect() - } - } - } - - /// Filter predicate shared by `history_full` and - /// `replay_entries_for_policy`. Always includes pre-segment bootstrap - /// and active-segment frames; also includes turn-lifecycle frames - /// (`amux/turn_started` / `amux/turn_complete` / `amux/turn_cancelled`) - /// from prior segments when their `amuxTurnId` is in `carry`. - fn full_view_includes_entry( - entry: &ReplayEntry, - active: Option, - carry: &HashSet, - ) -> bool { - if entry.segment_id == PRE_SEGMENT_ID || Some(entry.segment_id) == active { - return true; - } - Self::parse_turn_lifecycle_frame(&entry.frame) - .map(|(_, turn_id)| carry.contains(&turn_id)) - .unwrap_or(false) - } - - /// Compute the set of `amuxTurnId` values whose turn-lifecycle bookends - /// should be carried into the `full` view from prior segments. A turn - /// qualifies if any of its lifecycle frames (`amux/turn_started`, - /// `amux/turn_complete`, `amux/turn_cancelled`) is observed in the - /// pre-segment bootstrap or active segment, or if the turn is the - /// currently active one (its `turn_started` may sit in a prior - /// segment when the room rotated mid-turn). - fn cross_segment_turn_carry(&self) -> HashSet { - let Some(log) = self.replay_log.as_ref() else { - return HashSet::new(); - }; - let active = self.active_segment_id; - let mut carry = HashSet::new(); - for entry in log - .iter() - .filter(|e| e.segment_id == PRE_SEGMENT_ID || Some(e.segment_id) == active) - { - if let Some((_, turn_id)) = Self::parse_turn_lifecycle_frame(&entry.frame) { - carry.insert(turn_id); - } - } - if let Some(turn_id) = self.active_amux_turn_id { - carry.insert(turn_id.formatted()); - } - carry - } - - fn parse_turn_lifecycle_frame(frame: &Bytes) -> Option<(String, String)> { - let value: Value = serde_json::from_slice(frame).ok()?; - let method = value.get("method")?.as_str()?; - if !matches!( - method, - "amux/turn_started" | "amux/turn_complete" | "amux/turn_cancelled" - ) { - return None; - } - let turn_id = value - .get("params")? - .get("amuxTurnId")? - .as_str()? - .to_string(); - Some((method.to_string(), turn_id)) - } - - fn history_pending_only(&self) -> Vec { - self.pending_permission_frames - .iter() - .filter_map(|(_, frame)| Self::history_entry_from_frame(frame)) - .collect() - } - - fn apply_history_replay_order( - history: Vec, - replay_order: ReplayOrder, - ) -> Vec { - match replay_order { - ReplayOrder::Chronological => history, - ReplayOrder::NewestTurnFirst => Self::newest_turn_first_history(history), - } - } - - fn newest_turn_first_history(history: Vec) -> Vec { - let mut ambient = Vec::new(); - let mut turns: Vec> = Vec::new(); - let mut current_turn: Option> = None; - - for entry in history { - match entry.method.as_str() { - "amux/turn_started" => { - if let Some(turn) = current_turn.take() - && !turn.is_empty() - { - turns.push(turn); - } - current_turn = Some(vec![entry]); - } - "amux/turn_complete" => { - if let Some(mut turn) = current_turn.take() { - turn.push(entry); - turns.push(turn); - } else { - ambient.push(entry); - } - } - _ => { - if let Some(turn) = current_turn.as_mut() { - turn.push(entry); - } else { - ambient.push(entry); - } - } - } - } - - if let Some(turn) = current_turn - && !turn.is_empty() - { - turns.push(turn); - } - - ambient.extend(turns.into_iter().rev().flatten()); - ambient - } - - fn history_entry_from_frame(frame: &Bytes) -> Option { - let value: Value = serde_json::from_slice(frame).ok()?; - let method = value.get("method")?.as_str()?.to_string(); - let params = value.get("params").cloned().unwrap_or(Value::Null); - Some(HistoryEntry { method, params }) - } - - fn reissue_pending_permissions(&self, peer_id: &str) { - if self.pending_permission_frames.is_empty() { - return; - } - let Some(sub) = self.subscribers.get(peer_id) else { - return; - }; - for (_, frame) in &self.pending_permission_frames { - if sub.outbound.send(OutMsg::Frame(frame.clone())).is_err() { - tracing::debug!(%peer_id, "subscriber dropped during pending permission re-issue"); - return; - } - } - } - - pub(super) fn handle_detach(&mut self, peer_id: &str, req: IncomingRequest) { - let params: DetachParams = req - .params - .as_ref() - .map(|v| serde_json::from_value(v.clone()).unwrap_or_default()) - .unwrap_or_default(); - let resolved_session_id = self - .acp_session_id() - .map(str::to_string) - .unwrap_or_else(|| self.room_id.clone()); - if let Some(requested) = params.session_id.as_deref() - && !requested.is_empty() - && requested != resolved_session_id - && requested != self.room_id - { - self.send_error_response( - peer_id, - req.id, - attach::ATTACH_ERR_NOT_FOUND, - "session not found", - ); - return; - } - let result = DetachResult { - session_id: resolved_session_id, - status: "detached", - }; - let Ok(result) = serde_json::to_value(result) else { - self.send_error_response( - peer_id, - req.id, - attach::ATTACH_ERR_UNSUPPORTED, - "session/detach serialization failed", - ); - return; - }; - self.send_result_response(peer_id, req.id, result); - if let Some(sub) = self.subscribers.get(peer_id) { - let _ = sub.outbound.send(OutMsg::Close { - code: 1000, - reason: "client requested detach".to_string(), - }); - } - } -} diff --git a/src/room/mod.rs b/src/room/mod.rs deleted file mode 100644 index 90b8042..0000000 --- a/src/room/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod attach; -pub mod registry; -pub mod replay_store; -pub mod state; diff --git a/src/room/registry.rs b/src/room/registry.rs deleted file mode 100644 index 03ca4dd..0000000 --- a/src/room/registry.rs +++ /dev/null @@ -1,471 +0,0 @@ -//! Session registry: maps session ids to live session actors. -//! -//! On attach: if the session exists and its actor is still alive, the -//! subscriber joins it; otherwise a fresh agent subprocess is spawned and -//! a new session actor is started. The registry lock is released before -//! awaiting the actor's Attach ack to avoid head-of-line blocking on -//! concurrent attaches to different sessions. - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use serde_json::{Value, json}; -use thiserror::Error; -use tokio::sync::{Mutex, oneshot}; -use tokio::time::timeout; - -use crate::agent::process::AgentProcess; -use crate::cli::{ClientToolPolicy, ReplayTurns}; -use crate::multiplex::subscriber::Subscriber; -use crate::protocol::jsonrpc::{Id, Incoming, JsonRpcError, ParseError}; -use crate::room::replay_store::ReplayStore; -use crate::room::state::{ - AttachError, RoomHandle, RoomMsg, RoomOptions, RoomSnapshot, SessionListMetadataIndex, - spawn_room, -}; - -const CONTROL_PLANE_AGENT_TIMEOUT: Duration = Duration::from_secs(8); - -#[derive(Debug, Clone)] -pub struct AgentCmd { - pub program: String, - pub args: Vec, -} - -#[derive(Debug, Error)] -pub enum RegistryError { - #[error("server has no --agent-cmd configured")] - AgentCmdMissing, - #[error("peer_id already attached to this session")] - PeerIdInUse, - #[error("agent spawn failed: {0}")] - AgentSpawn(#[from] anyhow::Error), - #[error("session actor not reachable")] - ActorUnreachable, -} - -#[derive(Debug, Error)] -pub enum ControlPlaneSessionListError { - #[error("agent command not configured")] - AgentCmdMissing, - #[error("agent process failed: {0}")] - AgentProcess(#[from] anyhow::Error), - #[error("json encode/decode failed: {0}")] - Json(#[from] serde_json::Error), - #[error("agent protocol parse failed: {0}")] - Protocol(#[from] ParseError), - #[error("agent returned JSON-RPC error {code}: {message}")] - AgentJsonRpc { - code: i64, - message: String, - data: Option, - }, - #[error("agent did not respond to {method} before timeout")] - AgentTimeout { method: &'static str }, - #[error("agent exited before responding to {method}")] - AgentEof { method: &'static str }, -} - -impl From for ControlPlaneSessionListError { - fn from(err: JsonRpcError) -> Self { - Self::AgentJsonRpc { - code: err.code, - message: err.message, - data: err.data, - } - } -} - -async fn query_transient_session_list( - agent: &mut AgentProcess, - cwd: Option, -) -> Result { - let _initialize = request_transient_agent( - agent, - 1, - "initialize", - Some(json!({ "protocolVersion": 1 })), - ) - .await?; - - let params = cwd.map(|cwd| json!({ "cwd": cwd })); - request_transient_agent(agent, 2, "session/list", params).await -} - -async fn request_transient_agent( - agent: &mut AgentProcess, - id: i64, - method: &'static str, - params: Option, -) -> Result { - let mut request = json!({ - "jsonrpc": "2.0", - "id": id, - "method": method, - }); - if let Some(params) = params { - request["params"] = params; - } - let bytes = serde_json::to_vec(&request)?; - agent.send(&bytes).await?; - - for _ in 0..128 { - let line = timeout(CONTROL_PLANE_AGENT_TIMEOUT, agent.recv_line()) - .await - .map_err(|_| ControlPlaneSessionListError::AgentTimeout { method })? - .ok_or(ControlPlaneSessionListError::AgentEof { method })?; - let incoming = Incoming::parse(&line)?; - let Incoming::Response(response) = incoming else { - continue; - }; - if response.id != Id::Number(id) { - continue; - } - if let Some(error) = response.error { - return Err(error.into()); - } - return Ok(response.result.unwrap_or(Value::Null)); - } - - Err(ControlPlaneSessionListError::AgentTimeout { method }) -} - -pub struct RoomRegistry { - agent_cmd: Option, - replay_policy: ReplayTurns, - session_ttl: Duration, - meta_propagate: bool, - client_tool_policy: ClientToolPolicy, - emit_segment_frames: bool, - replay_store: Option>, - session_list_index: Arc, - sessions: Mutex>, -} - -impl RoomRegistry { - pub fn new( - agent_cmd: Option, - replay_policy: ReplayTurns, - session_ttl: Duration, - ) -> Arc { - Self::new_with_client_tool_policy( - agent_cmd, - replay_policy, - session_ttl, - false, - ClientToolPolicy::default(), - ) - } - - pub fn new_with_meta_propagation( - agent_cmd: Option, - replay_policy: ReplayTurns, - session_ttl: Duration, - meta_propagate: bool, - ) -> Arc { - Self::new_with_client_tool_policy( - agent_cmd, - replay_policy, - session_ttl, - meta_propagate, - ClientToolPolicy::default(), - ) - } - - pub fn new_with_client_tool_policy( - agent_cmd: Option, - replay_policy: ReplayTurns, - session_ttl: Duration, - meta_propagate: bool, - client_tool_policy: ClientToolPolicy, - ) -> Arc { - Self::new_with_options( - agent_cmd, - replay_policy, - session_ttl, - meta_propagate, - client_tool_policy, - true, - ) - } - - pub fn new_with_options( - agent_cmd: Option, - replay_policy: ReplayTurns, - session_ttl: Duration, - meta_propagate: bool, - client_tool_policy: ClientToolPolicy, - emit_segment_frames: bool, - ) -> Arc { - Self::new_with_replay_store( - agent_cmd, - replay_policy, - session_ttl, - meta_propagate, - client_tool_policy, - emit_segment_frames, - None, - ) - } - - pub fn new_with_replay_store( - agent_cmd: Option, - replay_policy: ReplayTurns, - session_ttl: Duration, - meta_propagate: bool, - client_tool_policy: ClientToolPolicy, - emit_segment_frames: bool, - replay_store: Option>, - ) -> Arc { - Self::new_full( - agent_cmd, - replay_policy, - session_ttl, - meta_propagate, - client_tool_policy, - emit_segment_frames, - replay_store, - ) - } - - #[allow(clippy::too_many_arguments)] - pub fn new_full( - agent_cmd: Option, - replay_policy: ReplayTurns, - session_ttl: Duration, - meta_propagate: bool, - client_tool_policy: ClientToolPolicy, - emit_segment_frames: bool, - replay_store: Option>, - ) -> Arc { - Arc::new(Self { - agent_cmd, - replay_policy, - session_ttl, - meta_propagate, - client_tool_policy, - emit_segment_frames, - replay_store, - session_list_index: Arc::new(SessionListMetadataIndex::new()), - sessions: Mutex::new(HashMap::new()), - }) - } - - /// Cold-start session discovery: spawn a transient agent subprocess, - /// initialize it, send `session/list`, return the agent's result, and - /// tear the subprocess down without registering a live mux session. - pub async fn list_sessions_control_plane( - &self, - cwd: Option, - ) -> Result { - let cmd = self - .agent_cmd - .clone() - .ok_or(ControlPlaneSessionListError::AgentCmdMissing)?; - let mut agent = AgentProcess::spawn(&cmd.program, &cmd.args).await?; - // The pump is lossy, so a chatty agent can never wedge itself - // even if nobody drains stderr. But we still want diagnostic - // visibility for the transient agent's stderr lines, so drain - // them into the mux's tracing logs here. - if let Some(mut stderr_rx) = agent.take_stderr_rx() { - tokio::spawn(async move { - while let Some(line) = stderr_rx.recv().await { - let text = String::from_utf8_lossy(&line); - tracing::debug!(target: "agent_stderr", control_plane = true, line = %text); - } - }); - } - let result = query_transient_session_list(&mut agent, cwd).await; - if let Err(err) = agent.shutdown(CONTROL_PLANE_AGENT_TIMEOUT).await { - tracing::warn!(error = %err, "transient session/list agent shutdown failed"); - } - result - } - - /// Attach a subscriber to `session_id`. Two paths: - /// - existing live session: send Attach over the actor channel and wait - /// for ack (so peer_id collision turns into PeerIdInUse). - /// - no live session (or dead handle): spawn the agent, start a new - /// actor with `subscriber` as the initial member. - pub async fn attach( - self: &Arc, - session_id: &str, - subscriber: Subscriber, - ) -> Result { - let existing = { - let mut sessions = self.sessions.lock().await; - match sessions.get(session_id) { - Some(h) if h.is_alive() => Some(h.clone()), - Some(_) => { - sessions.remove(session_id); - None - } - None => None, - } - }; - - if let Some(handle) = existing { - // Try to join the live session. If the actor died between the - // is_alive check and the send, fall through to spawn. - match self.try_join(&handle, subscriber).await { - Ok(()) => return Ok(handle), - Err(RegistryError::ActorUnreachable) => { - // Session died after our snapshot; fall through to - // spawn a fresh one. We can't recover the subscriber - // (it was consumed by try_join), so the WS attach - // returns ActorUnreachable in this rare race. - return Err(RegistryError::ActorUnreachable); - } - Err(other) => return Err(other), - } - } - - let mut sessions = self.sessions.lock().await; - // Re-check under lock: another attach() may have spawned us a - // session between our snapshot and now. If so, recurse-ish by - // re-running the try_join path on the now-live handle. But the - // subscriber has already been consumed if we got here from the - // first try_join branch — and we didn't, so it's still in hand. - if let Some(h) = sessions.get(session_id) { - if h.is_alive() { - let handle = h.clone(); - drop(sessions); - self.try_join(&handle, subscriber).await?; - return Ok(handle); - } - sessions.remove(session_id); - } - self.spawn_locked(&mut sessions, session_id, subscriber) - .await - } - - async fn spawn_locked( - self: &Arc, - sessions: &mut HashMap, - session_id: &str, - subscriber: Subscriber, - ) -> Result { - let cmd = self - .agent_cmd - .as_ref() - .ok_or(RegistryError::AgentCmdMissing)?; - let agent_cwd = std::env::current_dir() - .map(|path| path.to_string_lossy().to_string()) - .unwrap_or_else(|err| { - tracing::warn!(error = %err, "failed to read current dir for session context"); - String::new() - }); - let agent = AgentProcess::spawn(&cmd.program, &cmd.args) - .await - .map_err(RegistryError::AgentSpawn)?; - let (handle, _actor) = spawn_room( - subscriber, - agent, - session_id.to_string(), - RoomOptions { - replay_policy: self.replay_policy, - session_ttl: self.session_ttl, - meta_propagate: self.meta_propagate, - client_tool_policy: self.client_tool_policy, - session_list_index: self.session_list_index.clone(), - agent_cwd, - emit_segment_frames: self.emit_segment_frames, - replay_store: self.replay_store.clone(), - }, - ); - sessions.insert(session_id.to_string(), handle.clone()); - tracing::info!(session = %session_id, "spawned session"); - Ok(handle) - } - - async fn try_join( - &self, - handle: &RoomHandle, - subscriber: Subscriber, - ) -> Result<(), RegistryError> { - let (ack_tx, ack_rx) = oneshot::channel(); - handle - .tx - .send(RoomMsg::Attach { - subscriber, - ack: ack_tx, - }) - .await - .map_err(|_| RegistryError::ActorUnreachable)?; - match ack_rx.await { - Ok(Ok(())) => Ok(()), - Ok(Err(AttachError::PeerIdInUse)) => Err(RegistryError::PeerIdInUse), - Err(_) => Err(RegistryError::ActorUnreachable), - } - } - - /// Force-shutdown all sessions. Drops every RoomHandle, which causes - /// each actor to see its rx closed and exit, which drops subscriber - /// senders and shuts down the agent subprocess. - pub async fn shutdown(&self) { - let mut sessions = self.sessions.lock().await; - let count = sessions.len(); - sessions.clear(); - tracing::info!(sessions = count, "registry shutdown"); - } - - /// Snapshot every live session for `/debug/sessions`. Sessions whose - /// actors have exited (handle closed) are skipped. Each live session - /// gets a `Snapshot` RoomMsg and a short timeout; sessions that - /// don't reply in time are skipped with a warn. - pub async fn snapshot(&self) -> Vec { - let handles: Vec<(String, RoomHandle)> = { - let sessions = self.sessions.lock().await; - sessions - .iter() - .filter(|(_, h)| h.is_alive()) - .map(|(id, h)| (id.clone(), h.clone())) - .collect() - }; - - let mut out = Vec::with_capacity(handles.len()); - for (id, handle) in handles { - let (ack_tx, ack_rx) = oneshot::channel(); - if handle - .tx - .send(RoomMsg::Snapshot { ack: ack_tx }) - .await - .is_err() - { - tracing::debug!(session = %id, "session unreachable during snapshot"); - continue; - } - match tokio::time::timeout(std::time::Duration::from_millis(200), ack_rx).await { - Ok(Ok(snap)) => out.push(snap), - Ok(Err(_)) => { - tracing::debug!(session = %id, "session actor dropped snapshot ack"); - } - Err(_) => { - tracing::warn!(session = %id, "snapshot timed out"); - } - } - } - out - } - - /// Count of sessions whose actors are still alive. Used by the - /// integration tests and exposed publicly because they live in - /// `tests/` (the lib is built without `cfg(test)` when consumed - /// from an integration test). Safe to expose — it's already - /// visible via `/debug/sessions` over HTTP. - pub async fn live_session_count(&self) -> usize { - let sessions = self.sessions.lock().await; - sessions.values().filter(|h| h.is_alive()).count() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn control_plane_agent_timeout_allows_slow_agent_startup() { - assert!(CONTROL_PLANE_AGENT_TIMEOUT >= Duration::from_secs(8)); - } -} diff --git a/src/room/state.rs b/src/room/state.rs deleted file mode 100644 index c987eaf..0000000 --- a/src/room/state.rs +++ /dev/null @@ -1,3838 +0,0 @@ -//! Per-session state: agent subprocess + attached subscribers + the -//! actor task that serializes all mutations. -//! -//! All state mutation flows through a single tokio task driven by an mpsc -//! `RoomMsg` queue. Subscribers push inbound frames via -//! `InboundFromSubscriber` and detach via `Detach`. The agent's stdout pump -//! task forwards each NDJSON line as `AgentStdoutLine` and signals exit -//! via `AgentDied`. -//! -//! ## Routing contract -//! -//! Inbound (subscriber → agent), per JSON-RPC envelope shape: -//! - `notification` → forward to agent unchanged. -//! - `request` → check the `initialize` / `session/new` response cache; if -//! present, answer the subscriber locally without touching the agent. -//! Otherwise allocate a per-session `mux_id`, store the -//! `(peer_id, original_id)` mapping, rewrite the `id`, and forward. -//! Substantive (non-`initialize`) requests also mark the sender as the -//! current "driving subscriber" — surfaced in `amux/turn_started` and -//! `/debug/sessions`. Agent-initiated requests are broadcast (see the -//! Inbound (agent → subscribers) `request` arm below), so the driver -//! no longer has a privileged role at routing time. -//! - `session/prompt` requests participate in turn serialization: while a -//! prompt is in flight, a second ordinary `session/prompt` is rejected -//! locally with JSON-RPC error code `-32001` ("session busy"). Active-turn -//! steering/queueing uses explicit `amux/steer_active_turn` and -//! `amux/queue_prompt` requests. Hard steer is mux-owned cancel-and- -//! replace when a turn is active; idle steer becomes an immediate prompt; -//! queue is mux-owned queued prompt submission. The active turn clears when -//! the matching response returns from the agent. -//! - `response` → forward unchanged. Subscriber-originated responses only -//! show up as replies to agent-initiated requests, whose ids belong to -//! the agent's own id space (never our `mux_id` space), so they round -//! trip without rewriting. -//! -//! Inbound (agent → subscribers): -//! - `notification` → broadcast to every attached subscriber. -//! - `response` → look up `mux_id`, restore `original_id`, send to the -//! originator only. If the original request was the first `initialize` -//! or `session/new`, cache the `result` for later joiners. If it matches -//! `active_turn_mux_id`, clear the active turn. -//! - `request` → emit inert `amux/agent_request_opened` metadata, -//! broadcast the raw request to every live attached subscriber, and -//! record the agent's request id as `InFlight`. Whichever subscriber -//! replies first gets its response forwarded to the agent; the id -//! transitions to `Consumed` and any later responses with the same id -//! are dropped with a debug log. On the InFlight → Consumed transition -//! the mux also broadcasts `amux/agent_request_resolved { requestId, -//! resolvedBy, result | error }` so peers that lost the race (or never -//! replied) can dismiss the request from their UI. Replay clients see -//! the non-actionable opened/resolved lifecycle, not the stale raw ACP -//! request. This lets any attached peer (not just the driver) confirm -//! an agent-initiated request while preserving the JSON-RPC contract -//! that the agent sees exactly one reply per id. -//! -//! Turn-end cleanup: when the session/prompt response arrives and -//! `active_turn_mux_id` clears, the mux sweeps every `agent_pending` -//! entry still `InFlight`, transitions them to `Consumed`, and -//! broadcasts `amux/agent_request_resolved { resolvedBy: -//! "mux:turn-ended", result: null, error: null }` for each. This catches -//! the case where the agent times out an unanswered permission -//! internally (for example due to an agent-side permission timeout) and proceeds without writing a -//! response frame — without that sweep, TUI clients would be stuck -//! displaying a permission the agent has already abandoned. -//! -//! Frames that fail JSON-RPC envelope parsing fall back to raw broadcast -//! on the agent → subscribers direction (so non-JSON debug output, if any, -//! still reaches clients), and are dropped with a warn on the -//! subscriber → agent direction (we will not feed garbage to a real ACP -//! server). - -use std::collections::{BTreeMap, HashMap, VecDeque}; -use std::sync::{Arc, RwLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use bytes::Bytes; -use serde_json::{Map, Value, json}; -use tokio::sync::{mpsc, oneshot}; -use tokio::task::JoinHandle; - -use crate::agent::process::AgentProcess; -use crate::cli::{ClientToolMode, ClientToolPolicy, ReplayTurns}; -use crate::multiplex::subscriber::{OutMsg, Subscriber}; -use crate::protocol::amux::{self, AmuxTurnId, EndReason, SegmentId}; -use crate::protocol::attach::{self, SegmentSummary}; -use crate::protocol::jsonrpc::{ - Id, Incoming, IncomingRequest, IncomingResponse, JsonRpcError, JsonRpcVersion, -}; -pub use crate::room::attach::AttachStreamBackfill; -use crate::room::replay_store::ReplayStore; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SessionListAmuxMetadata { - pub room_id: String, - pub subscriber_count: usize, - pub driving_subscriber: Option, -} - -#[derive(Debug, Default)] -pub struct SessionListMetadataIndex { - by_acp_session_id: RwLock>, -} - -impl SessionListMetadataIndex { - pub fn new() -> Self { - Self::default() - } - - pub fn get(&self, acp_session_id: &str) -> Option { - self.by_acp_session_id - .read() - .expect("session list metadata index poisoned") - .get(acp_session_id) - .cloned() - } - - fn upsert(&self, acp_session_id: &str, metadata: SessionListAmuxMetadata) { - self.by_acp_session_id - .write() - .expect("session list metadata index poisoned") - .insert(acp_session_id.to_string(), metadata); - } - - fn remove_if_room(&self, acp_session_id: &str, room_id: &str) { - let mut index = self - .by_acp_session_id - .write() - .expect("session list metadata index poisoned"); - if index - .get(acp_session_id) - .is_some_and(|meta| meta.room_id == room_id) - { - index.remove(acp_session_id); - } - } -} - -const SESSION_QUEUE_CAPACITY: usize = 256; -const MAX_MUX_QUEUE_PROMPTS: usize = 6; -const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); - -/// Mux ids start at 1; 0 is reserved as a sentinel. -const FIRST_MUX_ID: u64 = 1; - -/// JSON-RPC error code returned to a subscriber that issues a second -/// `session/prompt` while another turn is already in flight. The -/// -32000..=-32099 range is reserved by the spec for implementation -/// defined errors; -32001 was chosen by the ROADMAP. -const SESSION_BUSY_ERROR_CODE: i64 = -32001; - -/// JSON-RPC error code returned for strict amux active-turn controls -/// that cannot be applied to the current turn state. -const NO_ACTIVE_TURN_ERROR_CODE: i64 = -32002; - -/// JSON-RPC error code returned when the mux-owned prompt queue is at -/// capacity. Kept distinct from ordinary `session/prompt` busy errors so -/// clients can render "queue full" rather than generic turn serialization. -const QUEUE_FULL_ERROR_CODE: i64 = -32003; - -/// JSON-RPC error code returned when a mux-owned queue item no longer -/// exists. Kept distinct from invalid params so clients can distinguish -/// malformed input from a remove/submit race. -const QUEUE_ITEM_NOT_FOUND_ERROR_CODE: i64 = -32004; - -/// Standard JSON-RPC invalid params code used when an amux control request -/// is missing its text/session payload. -const INVALID_PARAMS_ERROR_CODE: i64 = -32602; - -/// JSON-RPC error code for implementation-defined ACP client-tool policy -/// rejections. The structured `data.reason` distinguishes this from other -/// mux-owned failures. -const CLIENT_TOOL_BLOCKED_ERROR_CODE: i64 = -32000; - -/// WebSocket close code used when the agent subprocess exits while -/// subscribers are still attached. 1011 = "internal error" per RFC 6455. -const WS_CLOSE_AGENT_DEAD: u16 = 1011; - -/// JSON-RPC method name for the cancellation notification (request-cancellation -/// RFD; LSP-derived). Either direction may emit it. -const CANCEL_REQUEST_METHOD: &str = "$/cancel_request"; -/// ACP-native session cancellation. Agents can wire this method to -/// its cooperative interrupt path, so active-turn cancellation must use -/// this session-scoped primitive rather than request-id cancellation. -const SESSION_CANCEL_METHOD: &str = "session/cancel"; - -#[derive(Debug, Clone)] -pub(super) struct ReplayEntry { - pub(super) frame: Bytes, - recorded_at: String, - pub(super) seq: u64, - /// Segment id at the time the frame was recorded. Frames recorded - /// before any segment opens (e.g. `initialize` handshake replies) - /// carry `SegmentId(0)` as a sentinel — those frames sit in no - /// segment and are not counted against any segment's range. - pub(super) segment_id: SegmentId, -} - -impl ReplayEntry { - fn new(seq: u64, frame: Bytes, segment_id: SegmentId) -> Self { - Self { - frame, - recorded_at: utc_rfc3339_now(), - seq, - segment_id, - } - } - - /// Rebuild a `ReplayEntry` from a persisted record. Preserves the - /// original `recorded_at` (criterion: replay metadata must use the - /// mux-recorded time, not now). - fn from_persisted(seq: u64, frame: Bytes, segment_id: SegmentId, recorded_at: String) -> Self { - Self { - frame, - recorded_at, - seq, - segment_id, - } - } - - pub(super) fn frame_for_replay(&self) -> Bytes { - inject_replay_metadata(&self.frame, &self.recorded_at, self.seq) - } -} - -/// Outcome of replaying the on-disk store into a fresh `RoomInner`. -struct Hydrated { - replay_store: Option, - segments: Vec, - active_segment_id: Option, - next_replay_seq: u64, - next_segment_id: u64, - /// Canonical ACP `sessionId` observed at the time the persisted log - /// was written. Restored so late joiners can `session/attach` with - /// the original `sessionId` before the fresh agent has had a chance - /// to issue a new `session/new` / `session/load`. The next - /// canonical-id change (fresh `session/new` returning a different - /// id, or `session/load`) rotates the segment as normal. - canonical_session_id: Option, -} - -impl Default for Hydrated { - /// Matches the no-store-configured baseline: 1 is the first id that - /// `RoomInner::new` historically used for both seq and segment id - /// allocators. - fn default() -> Self { - Self { - replay_store: None, - segments: Vec::new(), - active_segment_id: None, - next_replay_seq: 1, - next_segment_id: 1, - canonical_session_id: None, - } - } -} - -fn hydrate_from_store( - store: &Arc, - room_id: &str, - log: &mut VecDeque, -) -> Hydrated { - let mut handle = match store.open_room(room_id) { - Ok(h) => h, - Err(err) => { - tracing::error!( - room = %room_id, - error = %err, - "replay store: failed to open per-room handle; continuing without persistence", - ); - return Hydrated::default(); - } - }; - - let loaded = handle.take_loaded(); - let mut max_seq = 0u64; - let mut max_segment_id = 0u64; - let mut segments: Vec = Vec::new(); - let mut active_segment_id: Option = None; - - for record in &loaded { - max_seq = max_seq.max(record.seq); - max_segment_id = max_segment_id.max(record.segment_id); - rebuild_segment_from_frame( - &record.frame, - SegmentId(record.segment_id), - record.seq, - &record.recorded_at, - &mut segments, - &mut active_segment_id, - ); - log.push_back(ReplayEntry::from_persisted( - record.seq, - record.frame_bytes(), - SegmentId(record.segment_id), - record.recorded_at.clone(), - )); - } - - if !loaded.is_empty() { - tracing::info!( - room = %room_id, - frames = loaded.len(), - segments = segments.len(), - "replay store: hydrated room from disk", - ); - } - - // The most recently observed acpSessionId — taken from the - // latest segment that carries one. This lets late joiners attach - // to the prior session id before the fresh agent has spoken. - let canonical_session_id = segments - .iter() - .rev() - .find_map(|seg| seg.acp_session_id.clone()); - - Hydrated { - replay_store: Some(handle), - segments, - active_segment_id, - next_replay_seq: max_seq.saturating_add(1).max(1), - next_segment_id: max_segment_id.saturating_add(1).max(1), - canonical_session_id, - } -} - -/// Walk a persisted broadcast frame and update the rebuilt `segments` -/// vec / `active_segment_id` based on `amux/segment_started` and -/// `amux/segment_ended` notifications. Other frames are ignored. -/// -/// Restored fields per segment, sourced from the bookend frame's -/// `params`: `acp_session_id` and `end_reason`. The -/// `opened_replay_seq` / `closed_replay_seq` come from the persisted -/// record's `seq` so cross-restart segment-bracket slicing stays -/// correct. -fn rebuild_segment_from_frame( - frame: &Value, - frame_segment_id: SegmentId, - record_seq: u64, - recorded_at: &str, - segments: &mut Vec, - active_segment_id: &mut Option, -) { - let Some(method) = frame.get("method").and_then(Value::as_str) else { - return; - }; - let params = frame.get("params").and_then(Value::as_object); - match method { - "amux/segment_started" => { - let acp_session_id = params - .and_then(|p| p.get("acpSessionId")) - .and_then(Value::as_str) - .map(str::to_string); - let mut seg = Segment::open(frame_segment_id, acp_session_id, record_seq); - seg.opened_at = recorded_at.to_string(); - // De-dup: a `segment_started` bookend lives in its own - // segment, so a second observation of the same id would be - // unexpected. Skip if already present. - if !segments.iter().any(|s| s.id == frame_segment_id) { - segments.push(seg); - } - *active_segment_id = Some(frame_segment_id); - } - "amux/segment_ended" => { - // SegmentId serializes as the string "seg-N"; accept either - // shape for forward/backward compatibility (older persisted - // logs may have carried a raw u64). - let closing_id = params - .and_then(|p| p.get("segmentId")) - .and_then(parse_segment_id_value) - .unwrap_or(frame_segment_id); - let end_reason = params - .and_then(|p| p.get("endReason")) - .and_then(|v| serde_json::from_value::(v.clone()).ok()); - if let Some(seg) = segments.iter_mut().find(|s| s.id == closing_id) { - seg.closed_at = Some(recorded_at.to_string()); - seg.closed_replay_seq = Some(record_seq); - if seg.end_reason.is_none() { - seg.end_reason = end_reason; - } - } - if *active_segment_id == Some(closing_id) { - *active_segment_id = None; - } - } - _ => {} - } -} - -fn parse_segment_id_value(value: &Value) -> Option { - if let Some(n) = value.as_u64() { - return Some(SegmentId(n)); - } - let s = value.as_str()?; - let trimmed = s.strip_prefix("seg-").unwrap_or(s); - trimmed.parse::().ok().map(SegmentId) -} - -/// Sentinel segment id used for frames recorded before any canonical ACP -/// session id has been captured (i.e. before the first segment opens). -pub(super) const PRE_SEGMENT_ID: SegmentId = SegmentId(0); - -fn inject_replay_metadata(frame: &Bytes, recorded_at: &str, replay_seq: u64) -> Bytes { - let Ok(mut value) = serde_json::from_slice::(frame) else { - return frame.clone(); - }; - let Value::Object(root) = &mut value else { - return frame.clone(); - }; - - let Some(params) = object_field(root, "params") else { - return frame.clone(); - }; - let Some(meta) = object_field(params, "_meta") else { - return frame.clone(); - }; - let Some(amux) = object_field(meta, "amux") else { - return frame.clone(); - }; - amux.insert( - "recordedAt".to_string(), - Value::String(recorded_at.to_string()), - ); - amux.insert( - "replaySeq".to_string(), - Value::Number(serde_json::Number::from(replay_seq)), - ); - - serde_json::to_vec(&value) - .map(Bytes::from) - .unwrap_or_else(|err| { - tracing::warn!(error = %err, "failed to serialize replay metadata frame; replaying original"); - frame.clone() - }) -} - -struct RequestTrace<'a> { - peer_id: &'a str, - peer_name: Option<&'a str>, - role: Option<&'a str>, - mux_id: u64, - amux_turn_id: Option, -} - -fn inject_request_trace_metadata(req: &mut IncomingRequest, trace: RequestTrace<'_>) { - let Some(params) = object_params(req) else { - return; - }; - let Some(meta) = object_field(params, "_meta") else { - return; - }; - let Some(amux) = object_field(meta, "amux") else { - return; - }; - - amux.insert( - "peerId".to_string(), - Value::String(trace.peer_id.to_string()), - ); - if let Some(peer_name) = trace.peer_name { - amux.insert("peerName".to_string(), Value::String(peer_name.to_string())); - } - if let Some(role) = trace.role { - amux.insert("role".to_string(), Value::String(role.to_string())); - } - amux.insert( - "muxId".to_string(), - Value::Number(serde_json::Number::from(trace.mux_id)), - ); - if let Some(turn_id) = trace.amux_turn_id { - amux.insert("amuxTurnId".to_string(), Value::String(turn_id.formatted())); - } -} - -fn inject_session_list_amux_metadata(session: &mut Value, metadata: &SessionListAmuxMetadata) { - let Value::Object(session) = session else { - return; - }; - let Some(meta) = object_field(session, "_meta") else { - return; - }; - let Some(amux) = object_field(meta, "amux") else { - return; - }; - - amux.insert( - "roomId".to_string(), - Value::String(metadata.room_id.clone()), - ); - amux.insert( - "subscriberCount".to_string(), - Value::Number(serde_json::Number::from(metadata.subscriber_count)), - ); - if let Some(driving_subscriber) = metadata.driving_subscriber.as_ref() { - amux.insert( - "drivingSubscriber".to_string(), - Value::String(driving_subscriber.clone()), - ); - } else { - amux.remove("drivingSubscriber"); - } -} - -fn object_params(req: &mut IncomingRequest) -> Option<&mut Map> { - let params = req.params.get_or_insert_with(|| Value::Object(Map::new())); - match params { - Value::Object(map) => Some(map), - _ => None, - } -} - -fn object_field<'a>( - object: &'a mut Map, - key: &str, -) -> Option<&'a mut Map> { - let value = object - .entry(key.to_string()) - .or_insert_with(|| Value::Object(Map::new())); - match value { - Value::Object(map) => Some(map), - _ => None, - } -} - -fn utc_rfc3339_now() -> String { - system_time_to_rfc3339_utc(SystemTime::now()) -} - -fn system_time_to_rfc3339_utc(time: SystemTime) -> String { - let duration = time.duration_since(UNIX_EPOCH).unwrap_or_default(); - let total_secs = duration.as_secs() as i64; - let days = total_secs.div_euclid(86_400); - let secs_of_day = total_secs.rem_euclid(86_400); - let (year, month, day) = civil_from_days(days); - let hour = secs_of_day / 3_600; - let minute = (secs_of_day % 3_600) / 60; - let second = secs_of_day % 60; - format!( - "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{nanos:09}Z", - nanos = duration.subsec_nanos(), - ) -} - -// Howard Hinnant's civil-from-days algorithm, with day 0 = 1970-01-01. -fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) { - let z = days_since_epoch + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let mut year = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = mp + if mp < 10 { 3 } else { -9 }; - if month <= 2 { - year += 1; - } - (year, month as u32, day as u32) -} - -/// Extract a non-null `requestId` from a `$/cancel_request` params object. -/// Per the RFD the field is `number | string`; we additionally treat -/// `Id::Null` as invalid (cancellation of an id-less notification is -/// meaningless). -fn parse_cancel_request_id(params: Option<&Value>) -> Option { - let id_value = params.and_then(|v| v.get("requestId"))?.clone(); - let id: Id = serde_json::from_value(id_value).ok()?; - match id { - Id::Null => None, - other => Some(other), - } -} - -/// Build a `$/cancel_request` notification frame as NDJSON bytes (no -/// trailing newline; the writer adds framing). Used both for forwarding -/// a subscriber-originated cancel with a translated id. -fn build_cancel_request(request_id: Id) -> Vec { - #[derive(serde::Serialize)] - struct CancelParams<'a> { - #[serde(rename = "requestId")] - request_id: &'a Id, - } - #[derive(serde::Serialize)] - struct CancelFrame<'a> { - jsonrpc: &'static str, - method: &'static str, - params: CancelParams<'a>, - } - serde_json::to_vec(&CancelFrame { - jsonrpc: "2.0", - method: CANCEL_REQUEST_METHOD, - params: CancelParams { - request_id: &request_id, - }, - }) - .expect("cancel_request frame is always serializable") -} - -/// Build a `session/cancel` notification frame as NDJSON bytes (no -/// trailing newline; the writer adds framing). Used for -/// `amux/cancel_active_turn`, where the intended target is the active -/// ACP session/turn rather than a JSON-RPC request id. -fn build_session_cancel(session_id: &str) -> Vec { - #[derive(serde::Serialize)] - #[serde(rename_all = "camelCase")] - struct CancelParams<'a> { - session_id: &'a str, - } - #[derive(serde::Serialize)] - struct CancelFrame<'a> { - jsonrpc: &'static str, - method: &'static str, - params: CancelParams<'a>, - } - serde_json::to_vec(&CancelFrame { - jsonrpc: "2.0", - method: SESSION_CANCEL_METHOD, - params: CancelParams { session_id }, - }) - .expect("session/cancel frame is always serializable") -} - -fn build_client_tool_blocked_response(id: Id, method: &str) -> Vec { - let resp = IncomingResponse { - jsonrpc: JsonRpcVersion, - id, - result: None, - error: Some(JsonRpcError { - code: CLIENT_TOOL_BLOCKED_ERROR_CODE, - message: format!("client tool request blocked by acp-mux policy: {method}"), - data: Some(json!({ - "reason": "client_tool_blocked", - "method": method, - "policy": "block", - })), - }), - }; - serde_json::to_vec(&resp).expect("client-tool blocked response is always serializable") -} - -/// Extract session/update counts from replay entries for debug snapshots. -fn replay_log_update_counts_by_acp_session_id( - log: &VecDeque, -) -> BTreeMap { - let mut counts = BTreeMap::new(); - for entry in log { - let Ok(value) = serde_json::from_slice::(&entry.frame) else { - continue; - }; - if value.get("method").and_then(Value::as_str) != Some("session/update") { - continue; - } - let Some(session_id) = value - .get("params") - .and_then(|params| params.get("sessionId")) - .and_then(Value::as_str) - else { - continue; - }; - *counts.entry(session_id.to_string()).or_insert(0) += 1; - } - counts -} - -pub enum RoomMsg { - Attach { - subscriber: Subscriber, - ack: oneshot::Sender>, - }, - Detach { - peer_id: String, - }, - InboundFromSubscriber { - peer_id: String, - bytes: Vec, - }, - AgentStdoutLine(Vec), - AgentStderrLine(Vec), - AgentDied, - AttachStreamBackfill(AttachStreamBackfill), - /// Build a JSON snapshot of session state for `/debug/sessions`. - Snapshot { - ack: oneshot::Sender, - }, -} - -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReplayResetSnapshot { - pub loaded_session_id: String, - pub replay_generation: u64, - pub dropped_frame_count: usize, - pub retained_frame_count: usize, -} - -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Segment { - pub id: SegmentId, - #[serde(skip_serializing_if = "Option::is_none")] - pub acp_session_id: Option, - pub opened_at: String, - pub opened_replay_seq: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub closed_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub closed_replay_seq: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub end_reason: Option, -} - -impl Segment { - fn open(id: SegmentId, acp_session_id: Option, opened_replay_seq: u64) -> Self { - Self { - id, - acp_session_id, - opened_at: utc_rfc3339_now(), - opened_replay_seq, - closed_at: None, - closed_replay_seq: None, - end_reason: None, - } - } -} - -pub(crate) fn segment_summary(seg: &Segment) -> SegmentSummary { - SegmentSummary { - id: seg.id, - acp_session_id: seg.acp_session_id.clone(), - opened_at: seg.opened_at.clone(), - opened_replay_seq: seg.opened_replay_seq, - closed_at: seg.closed_at.clone(), - closed_replay_seq: seg.closed_replay_seq, - end_reason: seg.end_reason, - } -} - -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RoomSnapshot { - pub room_id: String, - pub agent_cwd: String, - pub subscribers: Vec, - pub pending_request_count: usize, - pub initialize_cached: bool, - pub cached_session_id: Option, - pub active_turn_mux_id: Option, - pub active_amux_turn_id: Option, - pub driving_subscriber: Option, - pub subprocess_dead: bool, - pub ttl_pending: bool, - pub replay_log_len: Option, - pub replay_generation: u64, - pub replay_log_update_frames_by_acp_session_id: Option>, - pub last_replay_reset: Option, - pub next_mux_id: u64, - pub next_amux_turn_id: u64, - /// Segments observed in this room, oldest→newest. Empty before the - /// first canonical ACP session id arrives. - pub segments: Vec, - pub active_segment_id: Option, -} - -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SubscriberSnapshot { - pub peer_id: String, - pub peer_name: Option, - pub role: Option, - pub is_driving: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AttachError { - PeerIdInUse, -} - -#[derive(Clone)] -pub struct RoomHandle { - pub tx: mpsc::Sender, -} - -impl RoomHandle { - pub fn is_alive(&self) -> bool { - !self.tx.is_closed() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum HandshakeKind { - Initialize, - SessionNew, - /// `session/load` request that asked the agent to switch to an - /// existing session id. On success the room's canonical session id - /// is rebound to this value so late joiners' `session/new` calls - /// return the loaded session (not the original one). Captured at - /// request-translation time from the client's `params.sessionId` - /// — re-reading it off the response isn't reliable because not - /// every agent echoes the loaded id back. - SessionLoad { - loaded_session_id: String, - replay_start_len: usize, - }, -} - -#[derive(Debug)] -pub(super) struct PendingRequest { - pub(super) peer_id: String, - original_id: Id, - handshake: Option, - decorate_session_list: bool, - deliver_response: bool, - queue_item_id: Option, -} - -/// Lifecycle of an agent-initiated request id while we wait for the first -/// subscriber to reply. `InFlight` accepts the next response; `Consumed` -/// drops all further responses for the same id with a debug log so the -/// agent never receives duplicate replies. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AgentReqState { - InFlight, - Consumed, -} - -#[derive(Debug, Clone)] -pub(super) enum QueuedPromptKind { - Prompt, - Queue, - HardSteer { supersedes_turn_id: AmuxTurnId }, -} - -#[derive(Debug, Clone)] -pub(super) struct QueuedPrompt { - pub(super) queue_item_id: Option, - pub(super) peer_id: String, - session_id: String, - prompt_text: String, - pub(super) kind: QueuedPromptKind, -} - -#[derive(Debug)] -struct ActiveControlParams { - session_id: String, - text: String, -} - -#[derive(Debug, Default)] -struct AgentLineAction { - session_empty: bool, - writes_to_agent: Vec>, -} - -impl AgentLineAction { - fn none() -> Self { - Self::default() - } - - fn session_empty(session_empty: bool) -> Self { - Self { - session_empty, - writes_to_agent: Vec::new(), - } - } - - fn write_to_agent(write_to_agent: Vec) -> Self { - Self { - session_empty: false, - writes_to_agent: vec![write_to_agent], - } - } -} - -fn text_from_text_only_prompt(prompt: &Value) -> Option { - let prompt = prompt.as_array()?; - if prompt.is_empty() { - return None; - } - - let mut text = String::new(); - for block in prompt { - let block_type = block.get("type").and_then(Value::as_str)?; - if block_type != "text" { - return None; - } - let block_text = block.get("text").and_then(Value::as_str)?; - text.push_str(block_text); - } - Some(text) -} - -fn build_hard_steer_prompt( - peer_id: &str, - supersedes_turn_id: AmuxTurnId, - original_prompt: Option<&str>, - steering_text: &str, -) -> String { - let original_prompt = original_prompt.unwrap_or("(unavailable/non-text)"); - // SAFETY: This prompt-injection template is only for trusted attached - // peers in a private mux session. If acp-mux ever exposes steer text to - // untrusted/public clients, revisit this plain format! construction and - // add explicit quoting/sandboxing for peer-controlled text. - format!( - "Active turn steered by peer `{peer_id}` (supersedes {supersedes}). Use the steer below to answer the original prompt.\n\nOriginal:\n{original_prompt}\n\nSteer:\n{steering_text}", - supersedes = supersedes_turn_id.formatted(), - ) -} - -#[derive(Debug)] -pub(super) struct RoomInner { - pub(super) room_id: String, - agent_cwd: String, - session_list_index: Arc, - canonical_session_id: Option, - pub(super) subscribers: HashMap, - next_mux_id: u64, - pub(super) pending: HashMap, - initialize_cache: Option, - session_new_cache: Option, - /// Last subscriber to issue a substantive (non-`initialize`) request. - /// Target for agent-initiated requests. Cleared when that subscriber - /// detaches; falls back to an arbitrary subscriber at routing time. - driving_subscriber_peer_id: Option, - /// `mux_id` of the in-flight `session/prompt`, if any. While set, a - /// second `session/prompt` is rejected locally with `-32001`. - pub(super) active_turn_mux_id: Option, - /// `amuxTurnId` paired with the in-flight `session/prompt`. Used to - /// bookend `amux/turn_started` and `amux/turn_complete`. - pub(super) active_amux_turn_id: Option, - /// Upstream ACP `sessionId` paired with the in-flight `session/prompt`. - /// Used to translate `amux/cancel_active_turn` into ACP-native - /// `session/cancel`. - active_turn_session_id: Option, - /// Text-only view of the in-flight prompt, used when hard steer needs - /// to inject the superseded prompt into the replacement prompt. - active_turn_prompt_text: Option, - /// Mux-owned queue of future prompts to submit after active turns settle. - pub(super) queued_prompts: VecDeque, - /// Monotonic per-session counter for queue ids. - next_queue_item_id: u64, - /// Monotonic per-session counter for `amuxTurnId` allocation. - next_amux_turn_id: u64, - /// Replay log. `None` when policy is `Disabled` (saves memory). - /// Otherwise, every broadcast-tier frame (amux/* + agent notifications) - /// is appended with mux-recorded provenance; new subscribers receive a - /// metadata-augmented snapshot at attach time. - pub(super) replay_log: Option>, - /// Monotonic per-session counter for replay provenance metadata. - next_replay_seq: u64, - /// Incremented whenever a successful `session/load` establishes a new - /// canonical upstream ACP session and the replay log is segmented. - pub(super) replay_generation: u64, - /// Last successful replay segmentation event, exposed through - /// `/debug/sessions` for operator diagnostics. - last_replay_reset: Option, - /// Sender back into this actor. Used to pace attach-stream backfill - /// pages through the same serialized queue as live traffic. - pub(super) self_tx: mpsc::Sender, - /// Opt-in propagation of mux-owned trace metadata into outbound - /// subscriber → agent requests under `params._meta.amux`. - meta_propagate: bool, - /// Policy for agent-initiated ACP client-tool request namespaces - /// (`fs/*`, `terminal/*`). - client_tool_policy: ClientToolPolicy, - /// State for every agent-initiated request id we have ever broadcast - /// in this session. `InFlight` until the first subscriber reply - /// arrives; `Consumed` thereafter. We keep `Consumed` ids around for - /// the session lifetime so late/duplicate responses can be recognized - /// and dropped instead of leaking back to the agent. - agent_pending: HashMap, - /// Original frames for unresolved agent-initiated - /// `session/request_permission` requests. RFD #533 requires these to be - /// re-issued to clients that attach after the first broadcast so the - /// permission remains actionable, not just visible in history. - pub(super) pending_permission_frames: Vec<(Id, Bytes)>, - /// Segments observed in this room, in open-order. The last entry is - /// the active segment when `active_segment_id` is `Some(..)`. Empty - /// before the first canonical ACP session id arrives. - pub(super) segments: Vec, - /// Currently-open segment, or `None` if no canonical session id has - /// been captured yet. - pub(super) active_segment_id: Option, - /// Monotonic id allocator for segments. First segment is `SegmentId(1)`. - next_segment_id: u64, - /// Whether to broadcast `amux/segment_started` / `amux/segment_ended` - /// frames on rotation. Default true; gated by `--emit-segment-frames`. - emit_segment_frames: bool, - /// Opt-in on-disk persistence for the broadcast replay log. Open - /// only when `--replay-store ` is configured and the replay log - /// itself is enabled. Disk-write failures are logged and dropped so - /// live fan-out is never blocked by storage issues. - replay_store: Option, -} - -impl RoomInner { - #[allow(clippy::too_many_arguments)] - fn new( - room_id: String, - agent_cwd: String, - replay_policy: ReplayTurns, - meta_propagate: bool, - client_tool_policy: ClientToolPolicy, - session_list_index: Arc, - self_tx: mpsc::Sender, - emit_segment_frames: bool, - replay_store: Option>, - ) -> Self { - let mut replay_log = match replay_policy { - ReplayTurns::Disabled => None, - ReplayTurns::Unbounded => Some(VecDeque::new()), - ReplayTurns::Bounded(n) => { - tracing::warn!( - bound = n, - "--replay-turns N (bounded eviction) accepted but not yet implemented; behaving as unbounded for v0.1", - ); - Some(VecDeque::new()) - } - }; - - // Open the per-room persistence handle and rehydrate any prior - // on-disk state. Only attempted when both the in-memory log is - // enabled and a store is configured; `--replay-turns 0` disables - // persistence transparently. - let mut hydrated = Hydrated::default(); - if replay_log.is_some() - && let Some(store) = replay_store.as_ref() - { - hydrated = hydrate_from_store(store, &room_id, replay_log.as_mut().unwrap()); - } - let Hydrated { - replay_store: room_store_handle, - segments, - active_segment_id, - next_replay_seq, - next_segment_id, - canonical_session_id, - } = hydrated; - - Self { - room_id, - agent_cwd, - session_list_index, - canonical_session_id, - subscribers: HashMap::new(), - next_mux_id: FIRST_MUX_ID, - pending: HashMap::new(), - initialize_cache: None, - session_new_cache: None, - driving_subscriber_peer_id: None, - active_turn_mux_id: None, - active_amux_turn_id: None, - active_turn_session_id: None, - active_turn_prompt_text: None, - queued_prompts: VecDeque::new(), - next_queue_item_id: 1, - next_amux_turn_id: 1, - replay_log, - next_replay_seq, - replay_generation: 0, - last_replay_reset: None, - self_tx, - meta_propagate, - client_tool_policy, - agent_pending: HashMap::new(), - pending_permission_frames: Vec::new(), - segments, - active_segment_id, - next_segment_id, - emit_segment_frames, - replay_store: room_store_handle, - } - } - - fn set_canonical_session_id(&mut self, acp_session_id: &str) { - self.set_canonical_session_id_with_reason(acp_session_id, EndReason::AcpSessionIdChanged); - } - - /// Sets the canonical ACP session id and, if the id observably - /// changed, rotates the segment. `reason` is consumed only when an - /// active segment is being closed — the first call that opens the - /// initial segment ignores it. - fn set_canonical_session_id_with_reason(&mut self, acp_session_id: &str, reason: EndReason) { - if self.canonical_session_id.as_deref() == Some(acp_session_id) { - self.publish_session_list_metadata(); - return; - } - if let Some(previous) = self - .canonical_session_id - .replace(acp_session_id.to_string()) - { - self.session_list_index - .remove_if_room(&previous, &self.room_id); - } - self.rotate_segment(Some(acp_session_id.to_string()), reason); - self.publish_session_list_metadata(); - } - - /// Open or rotate the active segment. When no segment is open this - /// just opens the initial one (no `amux/segment_ended` emitted). When - /// a segment is already open this closes it (recording `reason`), - /// broadcasts `amux/segment_ended`, then opens a fresh segment and - /// broadcasts `amux/segment_started`. Both lifecycle frames pass - /// through the regular `broadcast()` path so they're appended to the - /// transcript and tagged with the segment that owns them. - fn rotate_segment(&mut self, new_acp_session_id: Option, reason: EndReason) { - let now = utc_rfc3339_now(); - - // Initial open: no prior segment, no `amux/segment_ended`. - let Some(current_id) = self.active_segment_id else { - let id = SegmentId(self.next_segment_id); - self.next_segment_id = self.next_segment_id.saturating_add(1); - let mut seg = Segment::open(id, new_acp_session_id.clone(), self.next_replay_seq); - seg.opened_at = now.clone(); - self.segments.push(seg); - self.active_segment_id = Some(id); - - if self.emit_segment_frames { - let frame = - amux::segment_started(&self.room_id, id, new_acp_session_id.as_deref(), &now); - self.broadcast(frame); - } - return; - }; - - let previous_acp = self - .segments - .iter() - .find(|s| s.id == current_id) - .and_then(|s| s.acp_session_id.clone()); - - // Mark the closing metadata in place but defer `closed_replay_seq` - // — the `amux/segment_ended` bookend needs to land inside the - // closing segment, and broadcasting it below advances - // `next_replay_seq`. Setting `closed_replay_seq` afterwards keeps - // the bookend frame's seq inside the closing range. - if let Some(seg) = self.segments.iter_mut().find(|s| s.id == current_id) { - seg.closed_at = Some(now.clone()); - seg.end_reason = Some(reason); - } - - // Retarget queued prompts to the new ACP session id whenever the - // canonical id observably moves. - if let Some(new_acp) = new_acp_session_id.as_deref() - && previous_acp.as_deref() != Some(new_acp) - { - for item in &mut self.queued_prompts { - item.session_id = new_acp.to_string(); - } - } - - // Allocate the new segment id so segment_ended can reference it - // as `successorSegmentId`. - let new_id = SegmentId(self.next_segment_id); - self.next_segment_id = self.next_segment_id.saturating_add(1); - - // Broadcast `amux/segment_ended` while the OLD segment is still - // active — current_segment_id() returns current_id, so the frame - // is tagged with and lands inside the closing segment. - if self.emit_segment_frames { - let frame = amux::segment_ended(&self.room_id, current_id, &now, reason, Some(new_id)); - self.broadcast(frame); - } - - // Now finalize closed_replay_seq. next_replay_seq has been - // advanced past the bookend (or unchanged if --emit-segment-frames - // is off), so seq - 1 is either the seq of segment_ended or of - // the last regular frame in the segment. - let closed_replay_seq = self.next_replay_seq.saturating_sub(1); - if let Some(seg) = self.segments.iter_mut().find(|s| s.id == current_id) { - seg.closed_replay_seq = Some(closed_replay_seq); - } - - // Open the new segment. - let mut new_segment = - Segment::open(new_id, new_acp_session_id.clone(), self.next_replay_seq); - new_segment.opened_at = now.clone(); - self.segments.push(new_segment); - self.active_segment_id = Some(new_id); - - // Broadcast `amux/segment_started`. current_segment_id() now == - // new_id so this frame lands in the opening segment. - if self.emit_segment_frames { - let frame = - amux::segment_started(&self.room_id, new_id, new_acp_session_id.as_deref(), &now); - self.broadcast(frame); - } - } - - /// Segment id used to tag freshly recorded frames. Frames recorded - /// before any segment opens carry `PRE_SEGMENT_ID` so the transcript - /// can still slice on segment boundaries without a fallible lookup. - fn current_segment_id(&self) -> SegmentId { - self.active_segment_id.unwrap_or(PRE_SEGMENT_ID) - } - - fn publish_session_list_metadata(&self) { - let Some(acp_session_id) = self.canonical_session_id.as_deref() else { - return; - }; - if self.subscribers.is_empty() { - self.session_list_index - .remove_if_room(acp_session_id, &self.room_id); - return; - } - self.session_list_index.upsert( - acp_session_id, - SessionListAmuxMetadata { - room_id: self.room_id.clone(), - subscriber_count: self.subscribers.len(), - driving_subscriber: self.driving_subscriber_peer_id.clone(), - }, - ); - } - - fn clear_session_list_metadata(&self) { - if let Some(acp_session_id) = self.canonical_session_id.as_deref() { - self.session_list_index - .remove_if_room(acp_session_id, &self.room_id); - } - } - - pub(super) fn acp_session_id(&self) -> Option<&str> { - self.canonical_session_id.as_deref().or_else(|| { - self.session_new_cache - .as_ref() - .and_then(|v| v.get("sessionId")) - .and_then(Value::as_str) - }) - } - - fn decorate_session_list_response(&self, resp: &mut IncomingResponse) { - let Some(result) = resp.result.as_mut() else { - return; - }; - let Some(sessions) = result.get_mut("sessions").and_then(Value::as_array_mut) else { - return; - }; - for session in sessions { - let Some(acp_session_id) = session - .get("sessionId") - .and_then(Value::as_str) - .map(str::to_string) - else { - continue; - }; - let Some(metadata) = self.session_list_index.get(&acp_session_id) else { - continue; - }; - inject_session_list_amux_metadata(session, &metadata); - } - } - - /// Attach a subscriber. Order: - /// - /// 1. Snapshot the replay log (before newcomer's own peer_joined enters). - /// 2. Emit amux/peer_joined → broadcast to existing subs (newcomer is - /// not in the map yet) + append to log. - /// 3. Insert newcomer into subscriber map. - /// 4. Unless the subscriber opted out with transport `replay=skip`, - /// deliver the snapshot to the newcomer's outbound. The snapshot - /// contains every broadcast-tier frame that happened before this - /// attach, in order, so the newcomer reconstructs the session. - /// - /// Because the actor serializes all RoomMsg handling, no live frames - /// can interleave during this sequence. - fn attach(&mut self, subscriber: Subscriber) -> Result<(), AttachError> { - if self.subscribers.contains_key(&subscriber.peer_id) { - return Err(AttachError::PeerIdInUse); - } - let suppress_legacy_replay = subscriber.suppress_legacy_replay; - let snapshot: Vec = if suppress_legacy_replay { - Vec::new() - } else { - self.replay_log - .as_ref() - .map(|log| log.iter().cloned().collect()) - .unwrap_or_default() - }; - - let frame = amux::peer_joined( - &self.room_id, - &subscriber.peer_id, - subscriber.peer_name.as_deref(), - subscriber.role.as_deref(), - ); - self.broadcast(frame); - - let peer_id = subscriber.peer_id.clone(); - tracing::info!( - session = %self.room_id, - peer_id = %peer_id, - replay_frames = snapshot.len(), - suppress_legacy_replay, - "subscriber joined session", - ); - self.subscribers.insert(peer_id.clone(), subscriber); - self.publish_session_list_metadata(); - self.send_session_context_to(&peer_id); - - if let Some(sub) = self.subscribers.get(&peer_id) { - for entry in snapshot { - let frame = entry.frame_for_replay(); - if sub.outbound.send(OutMsg::Frame(frame)).is_err() { - tracing::debug!(%peer_id, "newcomer dropped during replay"); - break; - } - } - } - Ok(()) - } - - fn send_session_context_to(&self, peer_id: &str) { - let Some(sub) = self.subscribers.get(peer_id) else { - return; - }; - let frame = Bytes::from(amux::session_context(&self.room_id, &self.agent_cwd)); - if sub.outbound.send(OutMsg::Frame(frame)).is_err() { - tracing::debug!(%peer_id, "subscriber dropped before session context delivered"); - } - } - - /// Build a serializable snapshot of session state for /debug/sessions. - fn build_snapshot(&self, ttl_pending: bool) -> RoomSnapshot { - let subs: Vec = self - .subscribers - .values() - .map(|s| SubscriberSnapshot { - peer_id: s.peer_id.clone(), - peer_name: s.peer_name.clone(), - role: s.role.clone(), - is_driving: self.driving_subscriber_peer_id.as_ref() == Some(&s.peer_id), - }) - .collect(); - let cached_session_id = self.session_new_cache.as_ref().and_then(|v| { - v.get("sessionId") - .and_then(|s| s.as_str()) - .map(|s| s.to_string()) - }); - RoomSnapshot { - room_id: self.room_id.clone(), - agent_cwd: self.agent_cwd.clone(), - subscribers: subs, - pending_request_count: self.pending.len(), - initialize_cached: self.initialize_cache.is_some(), - cached_session_id, - active_turn_mux_id: self.active_turn_mux_id, - active_amux_turn_id: self.active_amux_turn_id.map(|t| t.formatted()), - driving_subscriber: self.driving_subscriber_peer_id.clone(), - subprocess_dead: false, - ttl_pending, - replay_log_len: self.replay_log.as_ref().map(|l| l.len()), - replay_generation: self.replay_generation, - segments: self.segments.clone(), - active_segment_id: self.active_segment_id, - replay_log_update_frames_by_acp_session_id: self - .replay_log - .as_ref() - .map(replay_log_update_counts_by_acp_session_id), - last_replay_reset: self.last_replay_reset.clone(), - next_mux_id: self.next_mux_id, - next_amux_turn_id: self.next_amux_turn_id, - } - } - - fn close_all_subscribers(&self, code: u16, reason: &str) { - for (peer_id, sub) in &self.subscribers { - let msg = OutMsg::Close { - code, - reason: reason.to_string(), - }; - if sub.outbound.send(msg).is_err() { - tracing::debug!(%peer_id, "subscriber already gone during close"); - } - } - } - - /// Returns true if the session should end (no subscribers left). - /// Emits `amux/peer_left` to every remaining subscriber. The mux does not - /// fabricate ACP `session/update` lifecycle notifications; clients that - /// need mux lifecycle should consume `amux/*`. - fn detach(&mut self, peer_id: &str) -> bool { - let removed = self.subscribers.remove(peer_id); - if removed.is_some() { - tracing::info!(session = %self.room_id, %peer_id, "subscriber detached"); - let frame = amux::peer_left(&self.room_id, peer_id); - self.broadcast(frame); - let orphaned_queue_item_ids: Vec = self - .queued_prompts - .iter() - .filter(|item| item.peer_id == peer_id) - .filter_map(|item| item.queue_item_id.clone()) - .collect(); - for queue_item_id in orphaned_queue_item_ids { - self.broadcast(amux::queue_item_orphaned( - &self.room_id, - &queue_item_id, - peer_id, - )); - } - } - if self.driving_subscriber_peer_id.as_deref() == Some(peer_id) { - self.driving_subscriber_peer_id = None; - } - if self.subscribers.is_empty() { - self.clear_session_list_metadata(); - tracing::info!(session = %self.room_id, "last subscriber gone; ending session"); - return true; - } - self.publish_session_list_metadata(); - false - } - - /// Process one inbound frame from a subscriber. Returns Ok(Some(bytes)) - /// when a frame should be written to the agent stdin; Ok(None) means - /// the frame was either dropped, answered locally from cache, or - /// otherwise handled without touching the agent. - fn handle_inbound(&mut self, peer_id: &str, bytes: Vec) -> Option> { - let frame = match Incoming::parse(&bytes) { - Ok(f) => f, - Err(err) => { - tracing::warn!( - session = %self.room_id, - %peer_id, - error = %err, - "invalid JSON-RPC frame from subscriber; dropping", - ); - return None; - } - }; - match frame { - Incoming::Notification(notif) => { - self.handle_subscriber_notification(peer_id, notif, bytes) - } - Incoming::Response(resp) => self.gate_subscriber_response(peer_id, resp, bytes), - Incoming::Request(req) => self.translate_outbound_request(peer_id, req), - } - } - - /// Intercept proxy-handled subscriber-emitted notifications - /// (`$/cancel_request`, `amux/cancel_active_turn`) before forwarding - /// the bytes verbatim. Returns Ok(Some(bytes)) when the frame should - /// be written to the agent stdin, Ok(None) when it was handled - /// entirely in the proxy. - fn handle_subscriber_notification( - &mut self, - peer_id: &str, - notif: crate::protocol::jsonrpc::IncomingNotification, - bytes: Vec, - ) -> Option> { - match notif.method.as_str() { - CANCEL_REQUEST_METHOD => self.handle_subscriber_cancel(peer_id, notif), - amux::METHOD_CANCEL_ACTIVE_TURN => self.handle_amux_cancel_active_turn(peer_id, notif), - _ => Some(bytes), - } - } - - /// Strict `$/cancel_request` from a subscriber: cancel the - /// subscriber's *own* in-flight request. Find `(peer_id, - /// original_id)` in `pending`, rewrite the notification's - /// `requestId` to the corresponding `mux_id`, forward to the agent. - /// Subscribers cannot cancel other subscribers' requests — the - /// JSON-RPC id space is per-connection — they'd use - /// `amux/cancel_active_turn` for cross-peer "stop this turn" - /// instead. - fn handle_subscriber_cancel( - &mut self, - peer_id: &str, - notif: crate::protocol::jsonrpc::IncomingNotification, - ) -> Option> { - let original_id = match parse_cancel_request_id(notif.params.as_ref()) { - Some(id) => id, - None => { - tracing::debug!( - session = %self.room_id, - %peer_id, - "subscriber $/cancel_request with invalid/null requestId; dropping", - ); - return None; - } - }; - - let Some(mux_id) = self.find_pending_mux_id(peer_id, &original_id) else { - tracing::debug!( - session = %self.room_id, - %peer_id, - id = ?original_id, - "subscriber $/cancel_request for unknown id; dropping", - ); - return None; - }; - - tracing::info!( - session = %self.room_id, - %peer_id, - ?original_id, - mux_id, - "forwarding subscriber-initiated $/cancel_request to agent", - ); - Some(build_cancel_request(Id::Number(mux_id as i64))) - } - - /// `amux/cancel_active_turn`: any attached peer can cancel the - /// in-flight turn. Resolves to ACP-native `session/cancel` toward - /// the agent using the active turn's ACP `sessionId`. Broadcasts - /// `amux/turn_cancelled` to all peers immediately (intent), while - /// `amux/turn_complete` follows later when the agent settles. - fn handle_amux_cancel_active_turn( - &mut self, - peer_id: &str, - notif: crate::protocol::jsonrpc::IncomingNotification, - ) -> Option> { - let Some(active_mux_id) = self.active_turn_mux_id else { - tracing::debug!( - session = %self.room_id, - %peer_id, - "amux/cancel_active_turn with no active turn; dropping", - ); - return None; - }; - let Some(amux_turn_id) = self.active_amux_turn_id else { - tracing::warn!( - session = %self.room_id, - %peer_id, - "active_turn_mux_id set but active_amux_turn_id missing; dropping cancel", - ); - return None; - }; - let Some(pending) = self.pending.get(&active_mux_id) else { - tracing::warn!( - session = %self.room_id, - %peer_id, - active_mux_id, - "active turn has no pending entry; dropping cancel", - ); - return None; - }; - let original_driver = pending.peer_id.clone(); - let Some(active_session_id) = self.active_turn_session_id.clone() else { - tracing::warn!( - session = %self.room_id, - %peer_id, - active_mux_id, - "active turn has no ACP sessionId; dropping cancel", - ); - return None; - }; - let reason = notif - .params - .as_ref() - .and_then(|v| v.get("reason")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - tracing::info!( - session = %self.room_id, - cancelled_by = %peer_id, - %original_driver, - active_mux_id, - acp_session_id = %active_session_id, - reason = ?reason, - "amux/cancel_active_turn sending session/cancel to agent", - ); - - let frame = amux::turn_cancelled( - &self.room_id, - amux_turn_id, - peer_id, - &original_driver, - reason.as_deref(), - ); - self.broadcast(frame); - - Some(build_session_cancel(&active_session_id)) - } - - /// Agent-emitted `$/cancel_request`: cancels an agent-initiated - /// request that's still InFlight in `agent_pending` (currently only - /// broadcast-tier requests such as `session/request_permission`; ACP - /// client-tool requests are policy-blocked by default before they - /// enter this lifecycle). Forward the cancellation to every - /// subscriber so their UIs dismiss, mark the entry Consumed to - /// swallow late replies, and emit the - /// `amux/agent_request_resolved` sibling for amux clients. - fn handle_agent_cancel( - &mut self, - notif: crate::protocol::jsonrpc::IncomingNotification, - line: Vec, - ) -> bool { - let request_id = match parse_cancel_request_id(notif.params.as_ref()) { - Some(id) => id, - None => { - tracing::debug!( - session = %self.room_id, - "agent $/cancel_request with invalid/null requestId; dropping", - ); - return false; - } - }; - match self.agent_pending.get_mut(&request_id) { - Some(state @ AgentReqState::InFlight) => { - *state = AgentReqState::Consumed; - self.pending_permission_frames - .retain(|(id, _)| id != &request_id); - tracing::info!( - session = %self.room_id, - id = ?request_id, - "agent cancelled in-flight agent-initiated request; broadcasting", - ); - } - Some(AgentReqState::Consumed) => { - tracing::debug!( - session = %self.room_id, - id = ?request_id, - "agent $/cancel_request for already-consumed id; broadcasting anyway so late UIs dismiss", - ); - } - None => { - tracing::debug!( - session = %self.room_id, - id = ?request_id, - "agent $/cancel_request for unknown id; broadcasting anyway", - ); - } - } - - // Forward the raw cancellation notification to every subscriber - // so RFD-aware clients see the standard JSON-RPC form. Capture - // the empty-after-fanout signal so the caller can wind down the - // session if this drained the last subscriber. - let mut session_empty = self.broadcast(line); - - // Emit the amux-namespace sibling so amux-aware clients - // dismiss without needing to recognize $/cancel_request. - // (The second broadcast is harmless if the map is already - // empty — it appends to the replay log either way.) - let request_id_value = match serde_json::to_value(&request_id) { - Ok(v) => v, - Err(err) => { - tracing::warn!( - session = %self.room_id, - error = %err, - "failed to serialize cancelled request id; skipping amux/agent_request_resolved", - ); - return session_empty; - } - }; - let frame = amux::agent_request_resolved( - &self.room_id, - &request_id_value, - amux::RESOLVED_BY_AGENT_CANCELLED, - None, - None, - ); - session_empty |= self.broadcast(frame); - session_empty - } - - /// Linear search through `pending` for the entry matching - /// `(peer_id, original_id)`. N is bounded by concurrent in-flight - /// requests per session — small in practice. Returns the `mux_id` - /// key. - fn find_pending_mux_id(&self, peer_id: &str, original_id: &Id) -> Option { - self.pending - .iter() - .find(|(_, pr)| pr.peer_id == peer_id && &pr.original_id == original_id) - .map(|(mux_id, _)| *mux_id) - } - - /// First-reply-wins gate for subscriber-originated responses. If the - /// id matches an agent-initiated request we broadcast, only the first - /// reply is forwarded; later replies for the same id are dropped. - /// Responses whose id is not tracked (stray replies, or replies - /// against ids the agent never asked us to multiplex) are forwarded - /// unchanged for robustness — the agent will ignore them if it has - /// no matching outstanding request. - fn gate_subscriber_response( - &mut self, - peer_id: &str, - resp: IncomingResponse, - bytes: Vec, - ) -> Option> { - enum Decision { - Forward, - Drop, - Passthrough, - } - let decision = match self.agent_pending.get_mut(&resp.id) { - Some(state @ AgentReqState::InFlight) => { - *state = AgentReqState::Consumed; - self.pending_permission_frames - .retain(|(id, _)| id != &resp.id); - Decision::Forward - } - Some(AgentReqState::Consumed) => Decision::Drop, - None => Decision::Passthrough, - }; - match decision { - Decision::Forward => { - tracing::debug!( - session = %self.room_id, - %peer_id, - id = ?resp.id, - "first reply to agent-initiated request; forwarding to agent", - ); - self.emit_agent_request_resolved(peer_id, &resp); - Some(bytes) - } - Decision::Drop => { - tracing::debug!( - session = %self.room_id, - %peer_id, - id = ?resp.id, - "duplicate reply to agent-initiated request; dropping", - ); - None - } - Decision::Passthrough => Some(bytes), - } - } - - /// Transition every `InFlight` `agent_pending` entry to `Consumed` - /// and broadcast a cleanup `amux/agent_request_resolved` for each. - /// Called at turn-end (`route_agent_response` clearing - /// `active_turn_mux_id`) — by that point any unresolved - /// agent-initiated request has been abandoned by the agent, - /// for example, internally times out at 60s and proceeds without - /// writing a response frame), so peers need to dismiss the prompt. - /// `result` and `error` are both `null` on the broadcast since no - /// reply was ever forwarded to the agent. - fn sweep_stale_agent_pending(&mut self, reason: &str) { - let stale_ids: Vec = self - .agent_pending - .iter() - .filter(|(_, state)| matches!(state, AgentReqState::InFlight)) - .map(|(id, _)| id.clone()) - .collect(); - if stale_ids.is_empty() { - return; - } - for id in &stale_ids { - if let Some(state) = self.agent_pending.get_mut(id) { - *state = AgentReqState::Consumed; - } - self.pending_permission_frames - .retain(|(pending_id, _)| pending_id != id); - } - tracing::info!( - session = %self.room_id, - stale_count = stale_ids.len(), - reason, - "sweeping unresolved agent-initiated requests", - ); - for id in stale_ids { - let request_id_value = match serde_json::to_value(&id) { - Ok(v) => v, - Err(err) => { - tracing::warn!( - session = %self.room_id, - error = %err, - "failed to serialize stale agent-request id; skipping cleanup broadcast", - ); - continue; - } - }; - let frame = - amux::agent_request_resolved(&self.room_id, &request_id_value, reason, None, None); - self.broadcast(frame); - } - } - - /// Broadcast `amux/agent_request_resolved` so peers can dismiss the - /// matching pending UI. Called once per agent-initiated request id - /// (on the InFlight → Consumed transition). The frame echoes the - /// winning subscriber's `result` or `error` verbatim. For - /// `session/request_permission`, the result is derived entirely from - /// `options[]` that was already broadcast in the request, so no new - /// information leaks. ACP client-tool requests (`fs/*`, - /// `terminal/*`) are not broadcast in the default policy. - fn emit_agent_request_resolved(&mut self, resolved_by: &str, resp: &IncomingResponse) { - let request_id_value = match serde_json::to_value(&resp.id) { - Ok(v) => v, - Err(err) => { - tracing::warn!( - session = %self.room_id, - error = %err, - "failed to serialize agent-request id; skipping resolved broadcast", - ); - return; - } - }; - let error_value = resp - .error - .as_ref() - .and_then(|e| serde_json::to_value(e).ok()); - let frame = amux::agent_request_resolved( - &self.room_id, - &request_id_value, - resolved_by, - resp.result.as_ref(), - error_value.as_ref(), - ); - self.broadcast(frame); - } - - fn sanitize_initialize_client_capabilities(&self, req: &mut IncomingRequest) { - let Some(Value::Object(params)) = req.params.as_mut() else { - return; - }; - let Some(Value::Object(client_capabilities)) = params.get_mut("clientCapabilities") else { - return; - }; - - let mut stripped = vec![]; - if self.client_tool_policy.fs == ClientToolMode::Block - && client_capabilities.remove("fs").is_some() - { - stripped.push("fs"); - } - if self.client_tool_policy.terminal == ClientToolMode::Block - && client_capabilities.remove("terminal").is_some() - { - stripped.push("terminal"); - } - if !stripped.is_empty() { - tracing::info!( - session = %self.room_id, - namespaces = ?stripped, - "stripped blocked client-tool capabilities from initialize", - ); - } - } - - fn parse_amux_active_turn_control_params( - &mut self, - peer_id: &str, - req: &IncomingRequest, - require_active_turn: bool, - ) -> Option { - if require_active_turn && self.active_turn_mux_id.is_none() { - self.send_error_response( - peer_id, - req.id.clone(), - NO_ACTIVE_TURN_ERROR_CODE, - "amux active-turn control requires an active turn", - ); - return None; - } - - let Some(Value::Object(params)) = req.params.as_ref() else { - self.send_error_response( - peer_id, - req.id.clone(), - INVALID_PARAMS_ERROR_CODE, - "amux control params must be an object", - ); - return None; - }; - - let text = match params.get("text") { - Some(Value::String(text)) => text.clone(), - Some(_) => { - self.send_error_response( - peer_id, - req.id.clone(), - INVALID_PARAMS_ERROR_CODE, - "amux control params.text must be a string", - ); - return None; - } - None => match params.get("prompt").and_then(text_from_text_only_prompt) { - Some(text) => text, - None => { - self.send_error_response( - peer_id, - req.id.clone(), - INVALID_PARAMS_ERROR_CODE, - "amux control params.text or text-only params.prompt is required", - ); - return None; - } - }, - }; - let text = text.trim(); - if text.is_empty() { - self.send_error_response( - peer_id, - req.id.clone(), - INVALID_PARAMS_ERROR_CODE, - "amux control text must be non-empty", - ); - return None; - } - - let requested_session_id = match params.get("sessionId") { - Some(Value::String(session_id)) => Some(session_id.clone()), - Some(_) => { - self.send_error_response( - peer_id, - req.id.clone(), - INVALID_PARAMS_ERROR_CODE, - "amux control params.sessionId must be a string when present", - ); - return None; - } - None => None, - }; - let active_session_id = self - .active_turn_session_id - .clone() - .or_else(|| self.canonical_session_id.clone()); - if let (Some(requested), Some(active)) = (&requested_session_id, &active_session_id) - && requested != active - { - self.send_error_response( - peer_id, - req.id.clone(), - INVALID_PARAMS_ERROR_CODE, - "amux control params.sessionId must match the active or canonical sessionId", - ); - return None; - } - let Some(session_id) = requested_session_id.or(active_session_id) else { - self.send_error_response( - peer_id, - req.id.clone(), - INVALID_PARAMS_ERROR_CODE, - "amux control could not determine an ACP sessionId", - ); - return None; - }; - - Some(ActiveControlParams { - session_id, - text: text.to_string(), - }) - } - - fn pending_queue_prompt_count(&self) -> usize { - self.queued_prompts - .iter() - .filter(|item| matches!(item.kind, QueuedPromptKind::Queue)) - .count() - } - - fn has_pending_hard_steer(&self) -> bool { - self.queued_prompts - .iter() - .any(|item| matches!(item.kind, QueuedPromptKind::HardSteer { .. })) - } - - fn handle_amux_queue_prompt_request( - &mut self, - peer_id: &str, - req: IncomingRequest, - ) -> Option> { - let control = self.parse_amux_active_turn_control_params(peer_id, &req, false)?; - if self.pending_queue_prompt_count() >= MAX_MUX_QUEUE_PROMPTS { - self.send_error_response(peer_id, req.id, QUEUE_FULL_ERROR_CODE, "queue full"); - return None; - } - let submit_immediately = self.active_turn_mux_id.is_none(); - let queue_item_id = format!("q-{}", self.next_queue_item_id); - self.next_queue_item_id += 1; - let (peer_name, role) = self - .subscribers - .get(peer_id) - .map(|s| (s.peer_name.as_deref(), s.role.as_deref())) - .unwrap_or((None, None)); - self.queued_prompts.push_back(QueuedPrompt { - queue_item_id: Some(queue_item_id.clone()), - peer_id: peer_id.to_string(), - session_id: control.session_id, - prompt_text: control.text.clone(), - kind: QueuedPromptKind::Queue, - }); - self.broadcast(amux::queue_item_added( - &self.room_id, - &queue_item_id, - peer_id, - peer_name, - role, - &control.text, - )); - let write_to_agent = submit_immediately - .then(|| self.submit_next_queued_prompt()) - .flatten(); - let status = if write_to_agent.is_some() { - "submitted" - } else { - "queued" - }; - self.send_result_response( - peer_id, - req.id, - json!({ "queueItemId": queue_item_id, "status": status }), - ); - write_to_agent - } - - fn handle_amux_unqueue_prompt_request( - &mut self, - peer_id: &str, - req: IncomingRequest, - ) -> Option> { - let Some(Value::Object(params)) = req.params.as_ref() else { - self.send_error_response( - peer_id, - req.id, - INVALID_PARAMS_ERROR_CODE, - "amux/unqueue_prompt params must be an object", - ); - return None; - }; - let Some(queue_item_id) = params.get("queueItemId").and_then(Value::as_str) else { - self.send_error_response( - peer_id, - req.id, - INVALID_PARAMS_ERROR_CODE, - "amux/unqueue_prompt params.queueItemId must be a string", - ); - return None; - }; - let queue_item_id = queue_item_id.trim().to_string(); - if queue_item_id.is_empty() { - self.send_error_response( - peer_id, - req.id, - INVALID_PARAMS_ERROR_CODE, - "amux/unqueue_prompt params.queueItemId must be non-empty", - ); - return None; - } - - let Some(position) = self - .queued_prompts - .iter() - .position(|item| item.queue_item_id.as_deref() == Some(queue_item_id.as_str())) - else { - self.send_error_response( - peer_id, - req.id, - QUEUE_ITEM_NOT_FOUND_ERROR_CODE, - "queue item not found", - ); - return None; - }; - self.queued_prompts.remove(position); - self.broadcast(amux::queue_item_removed( - &self.room_id, - queue_item_id.as_str(), - peer_id, - )); - self.send_result_response( - peer_id, - req.id, - json!({ "queueItemId": queue_item_id, "status": "removed" }), - ); - None - } - - fn handle_amux_steer_request( - &mut self, - peer_id: &str, - req: IncomingRequest, - ) -> Option> { - let control = self.parse_amux_active_turn_control_params(peer_id, &req, false)?; - if self.active_turn_mux_id.is_none() { - return self.handle_amux_idle_steer_request(peer_id, req.id, control); - } - if self.has_pending_hard_steer() { - self.send_error_response( - peer_id, - req.id, - NO_ACTIVE_TURN_ERROR_CODE, - "a hard steer is already pending for this turn", - ); - return None; - } - let active_mux_id = self.active_turn_mux_id?; - let supersedes_turn_id = self.active_amux_turn_id?; - let Some(active_session_id) = self.active_turn_session_id.clone() else { - self.send_error_response( - peer_id, - req.id.clone(), - INVALID_PARAMS_ERROR_CODE, - "amux control could not determine the active ACP sessionId", - ); - return None; - }; - let original_driver = self - .pending - .get(&active_mux_id) - .map(|pending| pending.peer_id.clone()) - .unwrap_or_else(|| peer_id.to_string()); - let original_prompt = self.active_turn_prompt_text.as_deref(); - let replacement_prompt = - build_hard_steer_prompt(peer_id, supersedes_turn_id, original_prompt, &control.text); - let (peer_name, role) = self - .subscribers - .get(peer_id) - .map(|s| (s.peer_name.as_deref(), s.role.as_deref())) - .unwrap_or((None, None)); - - self.broadcast(amux::control_submitted(amux::ControlSubmitted { - room_id: &self.room_id, - kind: "steer", - mode: "hard", - peer_id, - peer_name, - role, - amux_turn_id: Some(supersedes_turn_id), - text: &control.text, - })); - self.broadcast(amux::turn_cancelled( - &self.room_id, - supersedes_turn_id, - peer_id, - &original_driver, - Some("hard_steer"), - )); - self.queued_prompts.push_front(QueuedPrompt { - queue_item_id: None, - peer_id: peer_id.to_string(), - session_id: control.session_id, - prompt_text: replacement_prompt, - kind: QueuedPromptKind::HardSteer { supersedes_turn_id }, - }); - self.send_result_response( - peer_id, - req.id, - json!({ - "accepted": true, - "mode": "hard", - "supersedesTurnId": supersedes_turn_id.formatted(), - }), - ); - Some(build_session_cancel(&active_session_id)) - } - - fn handle_amux_idle_steer_request( - &mut self, - peer_id: &str, - req_id: Id, - control: ActiveControlParams, - ) -> Option> { - let turn_id = AmuxTurnId(self.next_amux_turn_id); - let (peer_name, role) = self - .subscribers - .get(peer_id) - .map(|s| (s.peer_name.as_deref(), s.role.as_deref())) - .unwrap_or((None, None)); - - self.broadcast(amux::control_submitted(amux::ControlSubmitted { - room_id: &self.room_id, - kind: "steer", - mode: "prompt", - peer_id, - peer_name, - role, - amux_turn_id: Some(turn_id), - text: &control.text, - })); - self.queued_prompts.push_front(QueuedPrompt { - queue_item_id: None, - peer_id: peer_id.to_string(), - session_id: control.session_id, - prompt_text: control.text, - kind: QueuedPromptKind::Prompt, - }); - let write_to_agent = self.submit_next_queued_prompt(); - self.send_result_response( - peer_id, - req_id, - json!({ - "accepted": true, - "mode": "prompt", - "status": "submitted", - "amuxTurnId": turn_id.formatted(), - }), - ); - write_to_agent - } - - fn translate_outbound_request( - &mut self, - peer_id: &str, - mut req: IncomingRequest, - ) -> Option> { - // RFD #533 attach/detach are proxy-local methods. The WebSocket - // transport peer is already attached; these logical ACP handshakes - // must not be forwarded to the wrapped agent. - if req.method == attach::METHOD_ATTACH { - self.handle_attach(peer_id, req); - return None; - } - if req.method == attach::METHOD_DETACH { - self.handle_detach(peer_id, req); - return None; - } - - // Cache short-circuits. A cached `session/new` still updates the - // driving subscriber — the subscriber asked the session a question, - // even if we answered it locally. - if req.method == "initialize" - && let Some(cached) = self.initialize_cache.clone() - { - self.send_cached_response(peer_id, req.id, cached); - return None; - } - if req.method == "session/new" - && let Some(cached) = self.session_new_cache.clone() - { - self.note_driving_subscriber(peer_id); - self.send_cached_response(peer_id, req.id, cached); - return None; - } - - match req.method.as_str() { - amux::METHOD_STEER_ACTIVE_TURN => { - return self.handle_amux_steer_request(peer_id, req); - } - amux::METHOD_QUEUE_PROMPT => { - return self.handle_amux_queue_prompt_request(peer_id, req); - } - amux::METHOD_UNQUEUE_PROMPT => { - return self.handle_amux_unqueue_prompt_request(peer_id, req); - } - _ => {} - }; - - // Turn serialization: a second concurrent ordinary `session/prompt` - // is rejected locally with -32001 and does NOT update the driver - // (the in-flight turn's originator stays the driver). Active-turn - // control must use the explicit amux/* request surface above; plain - // ACP `session/prompt` stays generic and serialized. - // Also broadcast an amux/session_busy notification for rejections - // so peers see the rejection. - if req.method == "session/prompt" - && let Some(active) = self.active_turn_mux_id - { - let held_by = self.pending.get(&active).map(|pr| pr.peer_id.clone()); - tracing::warn!( - session = %self.room_id, - %peer_id, - active_turn = active, - held_by = ?held_by, - "rejecting concurrent session/prompt with -32001", - ); - let busy_frame = amux::session_busy(&self.room_id, true, held_by.as_deref()); - self.broadcast(busy_frame); - self.send_error_response( - peer_id, - req.id, - SESSION_BUSY_ERROR_CODE, - "session busy: another turn is in flight", - ); - return None; - } - - if req.method == "initialize" { - self.sanitize_initialize_client_capabilities(&mut req); - } - - if req.method != "initialize" { - self.note_driving_subscriber(peer_id); - } - - let handshake = match req.method.as_str() { - "initialize" => Some(HandshakeKind::Initialize), - "session/new" => Some(HandshakeKind::SessionNew), - "session/load" => { - let replay_start_len = self.replay_log.as_ref().map(|log| log.len()).unwrap_or(0); - req.params - .as_ref() - .and_then(|p| p.get("sessionId")) - .and_then(|s| s.as_str()) - .map(|s| HandshakeKind::SessionLoad { - loaded_session_id: s.to_string(), - replay_start_len, - }) - } - _ => None, - }; - - let mux_id = self.next_mux_id; - self.next_mux_id += 1; - let original_id = req.id.clone(); - let is_prompt = req.method == "session/prompt"; - let active_turn_session_id = if is_prompt { - req.params - .as_ref() - .and_then(|p| p.get("sessionId")) - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| self.canonical_session_id.clone()) - } else { - None - }; - let active_turn_prompt_text = if is_prompt { - req.params - .as_ref() - .and_then(|p| p.get("prompt")) - .and_then(text_from_text_only_prompt) - } else { - None - }; - let decorate_session_list = req.method == "session/list"; - let amux_turn_id = if is_prompt { - let turn_id = AmuxTurnId(self.next_amux_turn_id); - self.next_amux_turn_id += 1; - Some(turn_id) - } else { - None - }; - self.pending.insert( - mux_id, - PendingRequest { - peer_id: peer_id.to_string(), - original_id, - handshake, - decorate_session_list, - deliver_response: true, - queue_item_id: None, - }, - ); - req.id = Id::Number(mux_id as i64); - - if self.meta_propagate { - let (peer_name, role) = self - .subscribers - .get(peer_id) - .map(|s| (s.peer_name.as_deref(), s.role.as_deref())) - .unwrap_or((None, None)); - inject_request_trace_metadata( - &mut req, - RequestTrace { - peer_id, - peer_name, - role, - mux_id, - amux_turn_id, - }, - ); - } - - match serde_json::to_vec(&req) { - Ok(out) => { - if let Some(turn_id) = amux_turn_id { - self.active_turn_mux_id = Some(mux_id); - self.active_amux_turn_id = Some(turn_id); - self.active_turn_session_id = active_turn_session_id; - self.active_turn_prompt_text = active_turn_prompt_text; - tracing::info!( - session = %self.room_id, - %peer_id, - mux_id, - amux_turn_id = %turn_id.formatted(), - acp_session_id = ?self.active_turn_session_id, - "session/prompt forwarded; active turn opened", - ); - self.emit_turn_started(peer_id, turn_id, req.params.as_ref(), None); - } - Some(out) - } - Err(err) => { - tracing::error!( - session = %self.room_id, - mux_id, - error = %err, - "failed to serialize translated request; dropping", - ); - self.pending.remove(&mux_id); - None - } - } - } - - /// Build and broadcast `amux/turn_started`. The `content` field carries - /// `params.prompt` verbatim; if missing we send `null`. - fn emit_turn_started( - &mut self, - peer_id: &str, - turn_id: AmuxTurnId, - params: Option<&Value>, - supersedes_turn_id: Option, - ) { - let null = Value::Null; - let content = params.and_then(|p| p.get("prompt")).unwrap_or(&null); - let (peer_name, role) = self - .subscribers - .get(peer_id) - .map(|s| (s.peer_name.clone(), s.role.clone())) - .unwrap_or((None, None)); - let frame = amux::turn_started( - &self.room_id, - turn_id, - peer_id, - peer_name.as_deref(), - role.as_deref(), - content, - supersedes_turn_id, - ); - self.broadcast(frame); - } - - /// Build and broadcast `amux/turn_complete`. `stop_reason` is the - /// `result.stopReason` value if present, else `null` (abnormal turns - /// land here in chunk 9; for chunk 7 only the happy path is wired). - fn emit_turn_complete(&mut self, turn_id: AmuxTurnId, result: Option<&Value>) { - let null = Value::Null; - let stop_reason = result.and_then(|r| r.get("stopReason")).unwrap_or(&null); - let frame = amux::turn_complete(&self.room_id, turn_id, stop_reason); - self.broadcast(frame); - } - - fn submit_next_queued_prompt(&mut self) -> Option> { - let item = self.queued_prompts.pop_front()?; - self.note_driving_subscriber(&item.peer_id); - - let mux_id = self.next_mux_id; - self.next_mux_id += 1; - let turn_id = AmuxTurnId(self.next_amux_turn_id); - self.next_amux_turn_id += 1; - let supersedes_turn_id = match item.kind { - QueuedPromptKind::Prompt => None, - QueuedPromptKind::Queue => None, - QueuedPromptKind::HardSteer { supersedes_turn_id } => Some(supersedes_turn_id), - }; - let queue_item_id = item.queue_item_id.clone(); - let params = json!({ - "sessionId": item.session_id, - "prompt": [{ "type": "text", "text": item.prompt_text }], - }); - let mut req = IncomingRequest { - jsonrpc: JsonRpcVersion, - id: Id::Number(mux_id as i64), - method: "session/prompt".to_string(), - params: Some(params), - }; - if self.meta_propagate { - let (peer_name, role) = self - .subscribers - .get(&item.peer_id) - .map(|s| (s.peer_name.as_deref(), s.role.as_deref())) - .unwrap_or((None, None)); - inject_request_trace_metadata( - &mut req, - RequestTrace { - peer_id: &item.peer_id, - peer_name, - role, - mux_id, - amux_turn_id: Some(turn_id), - }, - ); - } - let out = match serde_json::to_vec(&req) { - Ok(out) => out, - Err(err) => { - tracing::error!( - session = %self.room_id, - mux_id, - error = %err, - "failed to serialize mux-owned queued prompt; dropping", - ); - return None; - } - }; - - self.pending.insert( - mux_id, - PendingRequest { - peer_id: item.peer_id.clone(), - original_id: Id::Number(mux_id as i64), - handshake: None, - decorate_session_list: false, - deliver_response: false, - queue_item_id: queue_item_id.clone(), - }, - ); - self.active_turn_mux_id = Some(mux_id); - self.active_amux_turn_id = Some(turn_id); - self.active_turn_session_id = req - .params - .as_ref() - .and_then(|p| p.get("sessionId")) - .and_then(Value::as_str) - .map(str::to_string); - self.active_turn_prompt_text = req - .params - .as_ref() - .and_then(|p| p.get("prompt")) - .and_then(text_from_text_only_prompt); - tracing::info!( - session = %self.room_id, - peer_id = %item.peer_id, - mux_id, - amux_turn_id = %turn_id.formatted(), - queue_item_id = ?queue_item_id, - supersedes_turn_id = ?supersedes_turn_id.map(|t| t.formatted()), - "mux-owned prompt submitted; active turn opened", - ); - self.emit_turn_started( - &item.peer_id, - turn_id, - req.params.as_ref(), - supersedes_turn_id, - ); - if let Some(queue_item_id) = queue_item_id.as_deref() { - self.broadcast(amux::queue_item_submitted( - &self.room_id, - queue_item_id, - turn_id, - )); - } - Some(out) - } - - /// Rebind the room's canonical session id to `loaded` after a - /// successful `session/load`. Two cases: - /// - /// - **Existing `session_new_cache`**: mutate the `sessionId` field - /// in place. Other fields the upstream agent included in its - /// `session/new` response (e.g. agent-specific metadata) are - /// preserved so a late joiner's `session/new` call still gets - /// a structurally-valid response. - /// - **No prior `session_new_cache`** (client opened with - /// `initialize` → `session/load` directly): synthesize a minimal - /// `{"sessionId": loaded}` value. A late joiner gets just the id - /// — enough to operate, missing any agent-specific session/new - /// fields the room never observed. - /// - /// Idempotent; safe to call multiple times for the same loaded id. - fn rebind_canonical_session(&mut self, loaded: &str) { - match self.session_new_cache.as_mut() { - Some(Value::Object(obj)) => { - let previous = obj - .insert("sessionId".to_string(), Value::String(loaded.to_string())) - .and_then(|v| v.as_str().map(|s| s.to_string())); - tracing::info!( - session = %self.room_id, - previous = ?previous, - loaded, - "session/load: rebound canonical session id (existing cache)", - ); - } - _ => { - self.session_new_cache = Some(serde_json::json!({ - "sessionId": loaded, - })); - tracing::info!( - session = %self.room_id, - loaded, - "session/load: rebound canonical session id (synthesized cache)", - ); - } - } - for item in &mut self.queued_prompts { - item.session_id = loaded.to_string(); - } - } - - fn reset_replay_generation_after_load(&mut self, loaded: &str, replay_start_len: usize) { - self.replay_generation += 1; - let presence_frames = self.current_peer_joined_replay_frames(); - let mut dropped_frame_count = 0; - let mut retained_frame_count = 0; - - let mut retained_entries = None; - if let Some(log) = self.replay_log.as_mut() { - dropped_frame_count = replay_start_len.min(log.len()); - if dropped_frame_count > 0 { - log.drain(..dropped_frame_count); - } - retained_entries = Some(std::mem::take(log)); - } - - if let Some(mut retained_entries) = retained_entries { - let mut segmented = - VecDeque::with_capacity(presence_frames.len() + retained_entries.len()); - for frame in presence_frames { - let entry = ReplayEntry::new( - self.next_replay_seq, - Bytes::from(frame), - self.current_segment_id(), - ); - self.next_replay_seq += 1; - segmented.push_back(entry); - } - segmented.append(&mut retained_entries); - retained_frame_count = segmented.len(); - self.replay_log = Some(segmented); - } - - self.last_replay_reset = Some(ReplayResetSnapshot { - loaded_session_id: loaded.to_string(), - replay_generation: self.replay_generation, - dropped_frame_count, - retained_frame_count, - }); - tracing::info!( - session = %self.room_id, - loaded, - replay_generation = self.replay_generation, - dropped_frame_count, - retained_frame_count, - "session/load: segmented replay generation", - ); - } - - fn current_peer_joined_replay_frames(&self) -> Vec> { - let mut peers: Vec<_> = self.subscribers.values().collect(); - peers.sort_by(|a, b| a.peer_id.cmp(&b.peer_id)); - peers - .into_iter() - .map(|subscriber| { - amux::peer_joined( - &self.room_id, - &subscriber.peer_id, - subscriber.peer_name.as_deref(), - subscriber.role.as_deref(), - ) - }) - .collect() - } - - fn note_driving_subscriber(&mut self, peer_id: &str) { - if !self.subscribers.contains_key(peer_id) { - tracing::debug!( - session = %self.room_id, - %peer_id, - "skipping driving subscriber update for detached peer", - ); - return; - } - if self.driving_subscriber_peer_id.as_deref() != Some(peer_id) { - tracing::debug!(session = %self.room_id, %peer_id, "driving subscriber updated"); - self.driving_subscriber_peer_id = Some(peer_id.to_string()); - self.publish_session_list_metadata(); - } - } - - pub(super) fn send_error_response( - &self, - peer_id: &str, - original_id: Id, - code: i64, - message: &str, - ) { - let resp = IncomingResponse { - jsonrpc: JsonRpcVersion, - id: original_id, - result: None, - error: Some(JsonRpcError { - code, - message: message.to_string(), - data: None, - }), - }; - let bytes = match serde_json::to_vec(&resp) { - Ok(b) => Bytes::from(b), - Err(err) => { - tracing::error!(error = %err, "failed to serialize error response"); - return; - } - }; - if let Some(sub) = self.subscribers.get(peer_id) - && sub.outbound.send(OutMsg::Frame(bytes)).is_err() - { - tracing::debug!(%peer_id, "subscriber dropped before error response delivered"); - } - } - - pub(super) fn send_result_response(&self, peer_id: &str, original_id: Id, result: Value) { - let resp = IncomingResponse { - jsonrpc: JsonRpcVersion, - id: original_id, - result: Some(result), - error: None, - }; - let bytes = match serde_json::to_vec(&resp) { - Ok(b) => Bytes::from(b), - Err(err) => { - tracing::error!(error = %err, "failed to serialize result response"); - return; - } - }; - if let Some(sub) = self.subscribers.get(peer_id) - && sub.outbound.send(OutMsg::Frame(bytes)).is_err() - { - tracing::debug!(%peer_id, "subscriber dropped before result response delivered"); - } - } - - fn send_cached_response(&self, peer_id: &str, original_id: Id, cached: Value) { - let resp = IncomingResponse { - jsonrpc: JsonRpcVersion, - id: original_id, - result: Some(cached), - error: None, - }; - let bytes = match serde_json::to_vec(&resp) { - Ok(b) => Bytes::from(b), - Err(err) => { - tracing::error!(error = %err, "failed to serialize cached response"); - return; - } - }; - if let Some(sub) = self.subscribers.get(peer_id) - && sub.outbound.send(OutMsg::Frame(bytes)).is_err() - { - tracing::debug!(%peer_id, "subscriber dropped before cached response delivered"); - } - } - - /// Process one stderr line from the agent subprocess. The mux mirrors - /// every stderr line into its own logs. Raw stderr is never broadcast to - /// subscribers as transcript content — it stays in mux-side diagnostics. - #[cfg(not(test))] - fn mirror_agent_stderr_to_terminal(&self, line: &str) { - eprintln!("[AGENT {room}] {line}", room = self.room_id); - } - - #[cfg(test)] - fn mirror_agent_stderr_to_terminal(&self, _line: &str) { - // Keep the test runner's stderr quiet. - } - - pub(super) fn handle_agent_stderr_line(&mut self, raw: Vec) { - let line = String::from_utf8_lossy(&raw); - let line = line.as_ref(); - self.mirror_agent_stderr_to_terminal(line); - tracing::debug!(session = %self.room_id, agent_stderr = %line, "agent stderr"); - } - - fn handle_agent_line(&mut self, line: Vec) -> AgentLineAction { - let frame = match Incoming::parse(&line) { - Ok(f) => f, - Err(err) => { - tracing::warn!( - session = %self.room_id, - error = %err, - "invalid JSON-RPC frame from agent; falling back to raw broadcast", - ); - return AgentLineAction::session_empty(self.broadcast(line)); - } - }; - match frame { - Incoming::Notification(notif) => { - AgentLineAction::session_empty(self.handle_agent_notification(notif, line)) - } - Incoming::Response(resp) => match self.route_agent_response(resp) { - Some(write_to_agent) => AgentLineAction::write_to_agent(write_to_agent), - None => AgentLineAction::none(), - }, - Incoming::Request(req) => match self.route_agent_request(req, line) { - Some(write_to_agent) => AgentLineAction::write_to_agent(write_to_agent), - None => AgentLineAction::none(), - }, - } - } - - /// Agent-emitted notifications: most are broadcast-tier (forwarded to - /// every subscriber and appended to the replay log). `$/cancel_request` - /// is special — it cancels an *agent-initiated* request that's still - /// InFlight in `agent_pending`. We translate by id, forward the - /// cancellation to every subscriber, mark the entry Consumed, and emit - /// `amux/agent_request_resolved { resolvedBy: "agent:cancelled" }` so - /// peer UIs dismiss. - /// - /// The mux also peeks into notification params for observable ACP - /// `sessionId` changes. Provider-specific metadata is passed through - /// untouched and does not influence segment state. - fn handle_agent_notification( - &mut self, - notif: crate::protocol::jsonrpc::IncomingNotification, - line: Vec, - ) -> bool { - if notif.method == CANCEL_REQUEST_METHOD { - return self.handle_agent_cancel(notif, line); - } - self.detect_segment_signal_from_agent_notification(¬if); - self.broadcast(line) - } - - /// Inspect an agent notification's params for an observable ACP - /// `sessionId` change and rotate the segment if warranted. Pure - /// observation: it never reads or writes outside the parsed `Value`, - /// and emits no frames besides the ones `rotate_segment` itself produces. - fn detect_segment_signal_from_agent_notification( - &mut self, - notif: &crate::protocol::jsonrpc::IncomingNotification, - ) { - let Some(params) = notif.params.as_ref() else { - return; - }; - let Some(acp_session_id) = params.get("sessionId").and_then(Value::as_str) else { - return; - }; - let Some(active) = self.active_segment() else { - return; - }; - if active.acp_session_id.as_deref() == Some(acp_session_id) { - return; - } - self.rotate_segment( - Some(acp_session_id.to_string()), - EndReason::AcpSessionIdChanged, - ); - } - - fn active_segment(&self) -> Option<&Segment> { - let id = self.active_segment_id?; - self.segments.iter().find(|s| s.id == id) - } - - /// Fan out an agent-initiated request to every attached subscriber and - /// record the request id in `agent_pending` so the first subscriber - /// reply wins. The raw request is not replayed — replies are - /// per-subscriber, and rejoining peers shouldn't be asked to confirm - /// something already resolved. Instead, emit an inert - /// `amux/agent_request_opened` sibling through `broadcast` first so - /// the request context is durable for late joiners and ordered before - /// the matching `amux/agent_request_resolved` lifecycle event. - fn route_agent_request(&mut self, req: IncomingRequest, line: Vec) -> Option> { - let id = req.id.clone(); - if let Some(mode) = self.client_tool_policy.mode_for_method(&req.method) { - match mode { - ClientToolMode::Block => { - tracing::warn!( - session = %self.room_id, - id = ?id, - method = %req.method, - "blocking agent-initiated client-tool request by policy", - ); - return Some(build_client_tool_blocked_response(id, &req.method)); - } - ClientToolMode::UnsafeDebug => { - tracing::warn!( - session = %self.room_id, - id = ?id, - method = %req.method, - subscribers = self.subscribers.len(), - "UNSAFE: broadcasting agent-initiated client-tool request by explicit debug policy", - ); - } - } - } - if self.subscribers.is_empty() { - tracing::warn!( - session = %self.room_id, - id = ?id, - method = %req.method, - "agent-initiated request with no attached subscribers; dropping", - ); - return None; - } - self.agent_pending - .insert(id.clone(), AgentReqState::InFlight); - let request_id_value = match serde_json::to_value(&id) { - Ok(v) => v, - Err(err) => { - tracing::warn!( - session = %self.room_id, - error = %err, - id = ?id, - method = %req.method, - "failed to serialize agent-request id; skipping opened lifecycle broadcast", - ); - Value::Null - } - }; - let opened = amux::agent_request_opened( - &self.room_id, - &request_id_value, - &req.method, - req.params.as_ref(), - self.active_amux_turn_id, - ); - self.broadcast(opened); - tracing::debug!( - session = %self.room_id, - id = ?id, - method = %req.method, - subscribers = self.subscribers.len(), - amux_turn_id = ?self.active_amux_turn_id.map(|t| t.formatted()), - "broadcasting agent-initiated request", - ); - let frame = Bytes::from(line); - if req.method == "session/request_permission" { - self.pending_permission_frames - .retain(|(pending_id, _)| pending_id != &id); - self.pending_permission_frames - .push((id.clone(), frame.clone())); - } - self.subscribers.retain(|peer_id, sub| { - match sub.outbound.send(OutMsg::Frame(frame.clone())) { - Ok(()) => true, - Err(_) => { - tracing::debug!(%peer_id, "outbound channel closed; dropping subscriber"); - false - } - } - }); - None - } - - fn route_agent_response(&mut self, mut resp: IncomingResponse) -> Option> { - let mux_id = match resp.id { - Id::Number(n) if n >= 0 => n as u64, - ref other => { - tracing::warn!( - session = %self.room_id, - id = ?other, - "agent response with non-numeric or negative id; dropping", - ); - return None; - } - }; - let Some(pr) = self.pending.remove(&mux_id) else { - tracing::warn!(session = %self.room_id, mux_id, "no pending request matches agent response; dropping"); - return None; - }; - - let mut next_write = None; - if self.active_turn_mux_id == Some(mux_id) { - self.active_turn_mux_id = None; - self.active_turn_session_id = None; - self.active_turn_prompt_text = None; - let turn_id = self.active_amux_turn_id.take(); - tracing::info!( - session = %self.room_id, - mux_id, - amux_turn_id = ?turn_id.map(|t| t.formatted()), - "session/prompt response received; active turn cleared", - ); - // Sweep before emitting amux/turn_complete so subscribers see - // any abandoned-request cleanup events ahead of the turn - // closure. Any agent-initiated request still InFlight at this - // point was given up on by the agent (for example due to an - // agent-side permission timeout) without writing a - // response frame). - self.sweep_stale_agent_pending("mux:turn-ended"); - if let Some(turn_id) = turn_id { - self.emit_turn_complete(turn_id, resp.result.as_ref()); - if let Some(queue_item_id) = pr.queue_item_id.as_deref() { - let stop_reason = resp - .result - .as_ref() - .and_then(|r| r.get("stopReason")) - .cloned() - .unwrap_or(Value::Null); - self.broadcast(amux::queue_item_completed( - &self.room_id, - queue_item_id, - turn_id, - &stop_reason, - )); - } - } - next_write = self.submit_next_queued_prompt(); - } - - // First-success handshake response caching. `session/load` - // success rebinds the room's canonical session to the loaded - // id — late joiners that call `session/new` get the loaded - // session, not the previously-cached one. A failed load - // (error response) leaves the existing cache untouched. - if let Some(kind) = pr.handshake - && let Some(result) = &resp.result - { - match kind { - HandshakeKind::Initialize => { - if self.initialize_cache.is_none() { - tracing::info!(session = %self.room_id, "caching initialize result"); - self.initialize_cache = Some(result.clone()); - } - } - HandshakeKind::SessionNew => { - if self.session_new_cache.is_none() { - tracing::info!(session = %self.room_id, "caching session/new result"); - self.session_new_cache = Some(result.clone()); - } - if let Some(acp_session_id) = result.get("sessionId").and_then(Value::as_str) { - self.set_canonical_session_id(acp_session_id); - } - } - HandshakeKind::SessionLoad { - loaded_session_id, - replay_start_len, - } => { - self.rebind_canonical_session(&loaded_session_id); - self.set_canonical_session_id_with_reason( - &loaded_session_id, - EndReason::SessionLoad, - ); - self.reset_replay_generation_after_load(&loaded_session_id, replay_start_len); - } - } - } - - if pr.decorate_session_list { - self.decorate_session_list_response(&mut resp); - } - - if pr.deliver_response { - resp.id = pr.original_id; - let bytes = match serde_json::to_vec(&resp) { - Ok(b) => Bytes::from(b), - Err(err) => { - tracing::error!(error = %err, "failed to serialize translated response"); - return next_write; - } - }; - if let Some(sub) = self.subscribers.get(&pr.peer_id) { - if sub.outbound.send(OutMsg::Frame(bytes)).is_err() { - tracing::debug!(peer_id = %pr.peer_id, "subscriber dropped before response delivered"); - } - } else { - tracing::debug!(peer_id = %pr.peer_id, "originator no longer attached; dropping response"); - } - } - - next_write - } - - /// Send `frame` to every subscriber and append to the replay log if - /// enabled. Drops subscribers whose outbound channel has closed. - /// Returns `true` only when fan-out *drained* subscribers — i.e. - /// the map was non-empty going in and is empty coming out. A - /// broadcast against an already-empty map (e.g. the initial - /// `amux/peer_joined` emitted before the first subscriber is - /// inserted) returns `false` and emits no "ending session" log, - /// since no subscribers were lost. - /// - /// Only broadcast-tier frames flow through here: amux/* notifications - /// (including inert agent-request lifecycle metadata) and the agent's - /// session/update (and other notification-shaped) frames. - /// Per-subscriber frames (responses, raw actionable agent-initiated - /// requests) do NOT go through `broadcast` and are NOT logged. - fn broadcast(&mut self, frame: impl Into) -> bool { - let frame: Bytes = frame.into(); - let segment_id = self.current_segment_id(); - if let Some(log) = self.replay_log.as_mut() { - let entry = ReplayEntry::new(self.next_replay_seq, frame.clone(), segment_id); - if let Some(store) = self.replay_store.as_ref() - && let Err(err) = - store.append(entry.seq, segment_id.0, &entry.recorded_at, &entry.frame) - { - tracing::warn!( - room = %self.room_id, - seq = entry.seq, - error = %err, - "replay store: append failed; frame not persisted", - ); - } - self.next_replay_seq += 1; - log.push_back(entry); - } - let pre_fanout = self.subscribers.len(); - self.subscribers.retain(|peer_id, sub| { - match sub.outbound.send(OutMsg::Frame(frame.clone())) { - Ok(()) => true, - Err(_) => { - tracing::debug!(%peer_id, "outbound channel closed; dropping subscriber"); - false - } - } - }); - if pre_fanout > 0 && self.subscribers.is_empty() { - tracing::info!(session = %self.room_id, "no live subscribers after fan-out; ending session"); - return true; - } - false - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::protocol::attach::HistoryPolicy; - - fn test_inner() -> RoomInner { - let (tx, _rx) = mpsc::channel(1); - RoomInner::new( - "test-room".to_string(), - "/tmp".to_string(), - ReplayTurns::Disabled, - false, - ClientToolPolicy::default(), - Arc::new(SessionListMetadataIndex::new()), - tx, - true, - None, - ) - } - - fn test_inner_with_store(replay_store: Arc) -> RoomInner { - let (tx, _rx) = mpsc::channel(1); - RoomInner::new( - "hydrate-room".to_string(), - "/tmp".to_string(), - ReplayTurns::Unbounded, - false, - ClientToolPolicy::default(), - Arc::new(SessionListMetadataIndex::new()), - tx, - true, - Some(replay_store), - ) - } - - #[test] - fn first_canonical_session_id_opens_initial_segment() { - let mut inner = test_inner(); - assert!(inner.active_segment_id.is_none(), "no segment before id"); - assert!(inner.segments.is_empty()); - - inner.set_canonical_session_id("sess-mock"); - - assert_eq!(inner.active_segment_id, Some(SegmentId(1))); - assert_eq!(inner.segments.len(), 1); - let seg = &inner.segments[0]; - assert_eq!(seg.id, SegmentId(1)); - assert_eq!(seg.acp_session_id.as_deref(), Some("sess-mock")); - assert!(seg.closed_at.is_none()); - assert!(seg.end_reason.is_none()); - } - - #[test] - fn repeated_canonical_session_id_does_not_reopen_segment() { - let mut inner = test_inner(); - inner.set_canonical_session_id("sess-mock"); - inner.set_canonical_session_id("sess-mock"); - - assert_eq!(inner.segments.len(), 1, "same id must not open new segment"); - assert_eq!(inner.active_segment_id, Some(SegmentId(1))); - } - - #[test] - fn rotate_segment_closes_current_and_opens_new() { - let mut inner = test_inner(); - // Use the unbounded replay path so broadcasts are observable. - inner.replay_log = Some(VecDeque::new()); - - inner.set_canonical_session_id_with_reason("sess-1", EndReason::SessionLoad); - assert_eq!(inner.active_segment_id, Some(SegmentId(1))); - let seg1_opened_seq = inner.segments[0].opened_replay_seq; - - // Rotate explicitly: closes seg-1, opens seg-2. - inner.set_canonical_session_id_with_reason("sess-2", EndReason::SessionLoad); - - assert_eq!( - inner.segments.len(), - 2, - "second canonical id must open a second segment", - ); - assert_eq!(inner.active_segment_id, Some(SegmentId(2))); - - let closed = &inner.segments[0]; - assert_eq!(closed.id, SegmentId(1)); - assert!(closed.closed_at.is_some(), "closed_at populated"); - assert_eq!(closed.end_reason, Some(EndReason::SessionLoad)); - assert!(closed.closed_replay_seq.is_some()); - - let opened = &inner.segments[1]; - assert_eq!(opened.id, SegmentId(2)); - assert_eq!(opened.acp_session_id.as_deref(), Some("sess-2")); - assert!(opened.closed_at.is_none()); - - // segment_ended for seg-1 + segment_started for seg-2 emitted at - // initial open + at rotation. Two opens, one ended → 3 frames. - let log = inner.replay_log.as_ref().unwrap(); - let methods: Vec = log - .iter() - .filter_map(|e| { - let v: serde_json::Value = serde_json::from_slice(&e.frame).ok()?; - v.get("method")?.as_str().map(String::from) - }) - .collect(); - assert_eq!( - methods, - vec![ - "amux/segment_started", - "amux/segment_ended", - "amux/segment_started", - ], - "lifecycle frames in transcript: open seg-1, end seg-1, open seg-2", - ); - - // segment_ended must be tagged with the OLD segment (seg-1) so - // current-segment-only replay omits it cleanly. - let ended_entry = log - .iter() - .find(|e| { - let v: serde_json::Value = serde_json::from_slice(&e.frame).unwrap_or_default(); - v.get("method") - .and_then(|m| m.as_str()) - .map(|s| s == "amux/segment_ended") - .unwrap_or(false) - }) - .expect("segment_ended must exist"); - assert_eq!( - ended_entry.segment_id, - SegmentId(1), - "segment_ended sits in the closing segment", - ); - // closed_replay_seq must include the bookend frame so per-segment - // slicing on (opened_replay_seq..=closed_replay_seq) covers it. - assert_eq!( - closed.closed_replay_seq, - Some(ended_entry.seq), - "closed_replay_seq must cover the segment_ended bookend", - ); - - // opened_replay_seq for seg-2 must be strictly after seg-1's open. - assert!( - opened.opened_replay_seq > seg1_opened_seq, - "new segment's opened_replay_seq must monotonically advance", - ); - - // segment_started for seg-2 must be the first frame at-or-after - // seg-2's opened_replay_seq, completing the bookend invariant. - let started_entry = log - .iter() - .find(|e| e.segment_id == SegmentId(2)) - .expect("seg-2 must have at least one frame (segment_started)"); - let v: serde_json::Value = serde_json::from_slice(&started_entry.frame).unwrap_or_default(); - assert_eq!( - v["method"].as_str(), - Some("amux/segment_started"), - "first frame in new segment is the segment_started bookend", - ); - assert_eq!(started_entry.seq, opened.opened_replay_seq); - } - - fn make_notification( - method: &str, - params: Value, - ) -> crate::protocol::jsonrpc::IncomingNotification { - crate::protocol::jsonrpc::IncomingNotification { - jsonrpc: crate::protocol::jsonrpc::JsonRpcVersion, - method: method.to_string(), - params: Some(params), - } - } - - #[test] - fn provider_specific_metadata_does_not_rotate_segment_under_stable_acp_id() { - let mut inner = test_inner(); - inner.replay_log = Some(VecDeque::new()); - inner.set_canonical_session_id_with_reason("acp-stable", EndReason::SessionLoad); - - let notif = make_notification( - "session/update", - json!({ - "sessionId": "acp-stable", - "_meta": { - "vendor": { - "internalSessionId": "vendor-2", - "lastMode": "session_split" - } - } - }), - ); - inner.detect_segment_signal_from_agent_notification(¬if); - - assert_eq!( - inner.segments.len(), - 1, - "provider metadata is passthrough-only" - ); - assert_eq!( - inner.active_segment().unwrap().acp_session_id.as_deref(), - Some("acp-stable"), - ); - } - - #[test] - fn repeated_signals_with_same_acp_session_id_do_not_rotate() { - let mut inner = test_inner(); - inner.replay_log = Some(VecDeque::new()); - inner.set_canonical_session_id_with_reason("acp-1", EndReason::SessionLoad); - let notif = make_notification("session/update", json!({ "sessionId": "acp-1" })); - inner.detect_segment_signal_from_agent_notification(¬if); - inner.detect_segment_signal_from_agent_notification(¬if); - inner.detect_segment_signal_from_agent_notification(¬if); - - assert_eq!(inner.segments.len(), 1, "stable ACP id never rotates"); - } - - #[test] - fn history_full_returns_current_segment_only() { - let mut inner = test_inner(); - inner.replay_log = Some(VecDeque::new()); - - inner.set_canonical_session_id_with_reason("acp-1", EndReason::SessionLoad); - // Stuff a frame into seg-1. - let frame_seg1 = br#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-1","update":{"kind":"seg1-marker"}}}"#; - inner.broadcast(Bytes::from_static(frame_seg1)); - - inner.set_canonical_session_id_with_reason("acp-2", EndReason::SessionLoad); - let frame_seg2 = br#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-2","update":{"kind":"seg2-marker"}}}"#; - inner.broadcast(Bytes::from_static(frame_seg2)); - - let full = inner.history_full(); - let kinds: Vec<&str> = full - .iter() - .filter_map(|e| e.params.get("update")?.get("kind")?.as_str()) - .collect(); - assert_eq!( - kinds, - vec!["seg2-marker"], - "Full must surface only the active segment's update frames", - ); - - // Lineage view sees both updates in chronological order. - let lineage = inner.history_full_lineage(); - let lineage_kinds: Vec<&str> = lineage - .iter() - .filter_map(|e| e.params.get("update")?.get("kind")?.as_str()) - .collect(); - assert_eq!(lineage_kinds, vec!["seg1-marker", "seg2-marker"]); - } - - #[test] - fn stream_path_respects_history_policy() { - // The streaming-delivery path must honour `historyPolicy: full` = - // current-segment-only; without the filter it leaked all - // segments to clients that opted into `full`. - let mut inner = test_inner(); - inner.replay_log = Some(VecDeque::new()); - - inner.set_canonical_session_id_with_reason("acp-1", EndReason::SessionLoad); - inner.broadcast(Bytes::from_static( - br#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-1","update":{"kind":"seg1"}}}"#, - )); - inner.set_canonical_session_id_with_reason("acp-2", EndReason::SessionLoad); - inner.broadcast(Bytes::from_static( - br#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-2","update":{"kind":"seg2"}}}"#, - )); - - let full_entries = inner.replay_entries_for_policy(HistoryPolicy::Full); - let full_kinds: Vec = full_entries - .iter() - .filter_map(|entry| { - let v: serde_json::Value = serde_json::from_slice(&entry.frame).ok()?; - v.get("params")? - .get("update")? - .get("kind")? - .as_str() - .map(str::to_string) - }) - .collect(); - assert_eq!( - full_kinds, - vec!["seg2"], - "stream + full must only surface the active segment's updates", - ); - - let lineage_entries = inner.replay_entries_for_policy(HistoryPolicy::FullLineage); - let lineage_kinds: Vec = lineage_entries - .iter() - .filter_map(|entry| { - let v: serde_json::Value = serde_json::from_slice(&entry.frame).ok()?; - v.get("params")? - .get("update")? - .get("kind")? - .as_str() - .map(str::to_string) - }) - .collect(); - assert_eq!( - lineage_kinds, - vec!["seg1", "seg2"], - "stream + full_lineage must surface every segment's updates", - ); - } - - fn turn_started_frame(room_id: &str, amux_turn_id: u64, peer_id: &str) -> Bytes { - Bytes::from(format!( - r#"{{"jsonrpc":"2.0","method":"amux/turn_started","params":{{"roomId":"{room_id}","amuxTurnId":"at-{amux_turn_id}","peerId":"{peer_id}","content":[]}}}}"#, - )) - } - - fn turn_complete_frame(room_id: &str, amux_turn_id: u64) -> Bytes { - Bytes::from(format!( - r#"{{"jsonrpc":"2.0","method":"amux/turn_complete","params":{{"roomId":"{room_id}","amuxTurnId":"at-{amux_turn_id}","stopReason":"end_turn"}}}}"#, - )) - } - - fn update_frame(session_id: &str) -> Bytes { - Bytes::from(format!( - r#"{{"jsonrpc":"2.0","method":"session/update","params":{{"sessionId":"{session_id}","update":{{"kind":"agent_message_chunk"}}}}}}"#, - )) - } - - #[test] - fn history_full_carries_turn_started_when_turn_completed_across_segments() { - // turn_started lands in seg-1, then a segment rotation moves - // to seg-2 mid-turn, then turn_complete lands in seg-2. - // historyPolicy: full must carry the seg-1 turn_started forward so - // clients see a bracketed turn instead of an orphan turn_complete. - let mut inner = test_inner(); - inner.replay_log = Some(VecDeque::new()); - - inner.set_canonical_session_id_with_reason("acp-1", EndReason::SessionLoad); - inner.broadcast(turn_started_frame("test-room", 7, "alice")); - inner.broadcast(update_frame("acp-1")); - - inner.rotate_segment(Some("acp-2".to_string()), EndReason::AcpSessionIdChanged); - - inner.broadcast(update_frame("acp-1")); - inner.broadcast(turn_complete_frame("test-room", 7)); - - let history = inner.history_full(); - let methods: Vec<&str> = history.iter().map(|e| e.method.as_str()).collect(); - - // turn_started should appear (carried from seg-1) before turn_complete. - let started_idx = methods - .iter() - .position(|m| *m == "amux/turn_started") - .expect("turn_started must be carried into full view"); - let complete_idx = methods - .iter() - .position(|m| *m == "amux/turn_complete") - .expect("turn_complete must be present"); - assert!( - started_idx < complete_idx, - "carried turn_started must precede turn_complete in replay order", - ); - } - - #[test] - fn history_full_carries_turn_started_when_turn_is_still_active_across_segments() { - // turn_started lands in seg-1, rotation happens mid-turn, the turn - // has not yet completed when a late joiner attaches. The carry - // should pull turn_started forward by way of active_amux_turn_id. - let mut inner = test_inner(); - inner.replay_log = Some(VecDeque::new()); - - inner.set_canonical_session_id_with_reason("acp-1", EndReason::SessionLoad); - inner.broadcast(turn_started_frame("test-room", 11, "alice")); - inner.active_amux_turn_id = Some(AmuxTurnId(11)); - inner.active_turn_mux_id = Some(99); - inner.broadcast(update_frame("acp-1")); - - inner.rotate_segment(Some("acp-2".to_string()), EndReason::AcpSessionIdChanged); - inner.broadcast(update_frame("acp-1")); - // Turn still in flight: no turn_complete yet. - - let history = inner.history_full(); - let methods: Vec<&str> = history.iter().map(|e| e.method.as_str()).collect(); - assert!( - methods.contains(&"amux/turn_started"), - "active turn's started bookend must be carried even without turn_complete", - ); - } - - #[test] - fn history_full_does_not_carry_turn_that_completed_before_active_segment() { - // A turn fully contained in seg-1 (started AND completed there) - // followed by rotation into seg-2: the historyPolicy: full view - // for a late joiner attached in seg-2 must NOT show that prior - // turn — only frames in seg-2 plus any cross-segment bookend. - let mut inner = test_inner(); - inner.replay_log = Some(VecDeque::new()); - - inner.set_canonical_session_id_with_reason("acp-1", EndReason::SessionLoad); - inner.broadcast(turn_started_frame("test-room", 1, "alice")); - inner.broadcast(turn_complete_frame("test-room", 1)); - - inner.rotate_segment(Some("acp-2".to_string()), EndReason::SessionLoad); - inner.broadcast(turn_started_frame("test-room", 2, "alice")); - inner.broadcast(turn_complete_frame("test-room", 2)); - - let history = inner.history_full(); - let turn_ids: Vec<&str> = history - .iter() - .filter(|e| e.method == "amux/turn_started" || e.method == "amux/turn_complete") - .filter_map(|e| e.params.get("amuxTurnId")?.as_str()) - .collect(); - assert!( - turn_ids.iter().all(|id| *id == "at-2"), - "only the active segment's turn bookends should appear (got {turn_ids:?})", - ); - } - - #[test] - fn history_full_lineage_returns_every_segment_unchanged() { - // Full lineage path is unaffected by the carry rule — it already - // returns everything. Regression guard. - let mut inner = test_inner(); - inner.replay_log = Some(VecDeque::new()); - - inner.set_canonical_session_id_with_reason("acp-1", EndReason::SessionLoad); - inner.broadcast(turn_started_frame("test-room", 1, "alice")); - inner.broadcast(turn_complete_frame("test-room", 1)); - inner.rotate_segment(Some("acp-2".to_string()), EndReason::SessionLoad); - inner.broadcast(turn_started_frame("test-room", 2, "alice")); - inner.broadcast(turn_complete_frame("test-room", 2)); - - let history = inner.history_full_lineage(); - let bookends: Vec<&str> = history - .iter() - .filter(|e| e.method == "amux/turn_started" || e.method == "amux/turn_complete") - .filter_map(|e| e.params.get("amuxTurnId")?.as_str()) - .collect(); - assert_eq!(bookends, vec!["at-1", "at-1", "at-2", "at-2"]); - } - - #[test] - fn segment_summary_populates_after_rotation() { - let mut inner = test_inner(); - inner.replay_log = Some(VecDeque::new()); - inner.set_canonical_session_id_with_reason("acp-1", EndReason::SessionLoad); - inner.set_canonical_session_id_with_reason("acp-2", EndReason::SessionLoad); - - let summaries: Vec<_> = inner.segments.iter().map(segment_summary).collect(); - assert_eq!(summaries.len(), 2); - assert_eq!(summaries[0].id, SegmentId(1)); - assert_eq!(summaries[0].end_reason, Some(EndReason::SessionLoad)); - assert!(summaries[0].closed_at.is_some()); - assert_eq!(summaries[1].id, SegmentId(2)); - assert_eq!(summaries[1].acp_session_id.as_deref(), Some("acp-2")); - assert!(summaries[1].closed_at.is_none()); - } - - #[test] - fn rotate_segment_respects_emit_segment_frames_flag() { - let (tx, _rx) = mpsc::channel(1); - let mut inner = RoomInner::new( - "test-room".to_string(), - "/tmp".to_string(), - ReplayTurns::Unbounded, - false, - ClientToolPolicy::default(), - Arc::new(SessionListMetadataIndex::new()), - tx, - false, // emit_segment_frames OFF - None, - ); - - inner.set_canonical_session_id_with_reason("sess-1", EndReason::SessionLoad); - inner.set_canonical_session_id_with_reason("sess-2", EndReason::SessionLoad); - - // Internal state still rotates… - assert_eq!(inner.segments.len(), 2); - assert_eq!(inner.active_segment_id, Some(SegmentId(2))); - - // …but no segment lifecycle frames hit the transcript. - let log = inner.replay_log.as_ref().unwrap(); - let has_segment_method = log.iter().any(|e| { - let v: serde_json::Value = serde_json::from_slice(&e.frame).unwrap_or_default(); - v.get("method") - .and_then(|m| m.as_str()) - .map(|s| s.starts_with("amux/segment_")) - .unwrap_or(false) - }); - assert!( - !has_segment_method, - "--emit-segment-frames=false must suppress lifecycle frames", - ); - } - - #[test] - fn rebind_canonical_session_rewrites_queued_prompt_session_ids() { - let mut inner = test_inner(); - inner.queued_prompts.push_back(QueuedPrompt { - queue_item_id: Some("q-1".to_string()), - peer_id: "A".to_string(), - session_id: "sess-old".to_string(), - prompt_text: "queued".to_string(), - kind: QueuedPromptKind::Queue, - }); - inner.queued_prompts.push_back(QueuedPrompt { - queue_item_id: None, - peer_id: "B".to_string(), - session_id: "sess-old".to_string(), - prompt_text: "steered".to_string(), - kind: QueuedPromptKind::HardSteer { - supersedes_turn_id: AmuxTurnId(1), - }, - }); - - inner.rebind_canonical_session("sess-loaded"); - - assert!( - inner - .queued_prompts - .iter() - .all(|item| item.session_id == "sess-loaded"), - "session/load must retarget queued prompts to the newly loaded canonical session" - ); - } -} - -#[derive(Debug, Clone)] -pub struct RoomOptions { - pub replay_policy: ReplayTurns, - pub session_ttl: Duration, - pub meta_propagate: bool, - pub client_tool_policy: ClientToolPolicy, - pub session_list_index: Arc, - pub agent_cwd: String, - /// Whether to emit `amux/segment_started` / `amux/segment_ended` - /// lifecycle frames on rotation. Default true. Set false to preserve - /// byte-equivalence with v0.1.x clients during the rooms rollout. - pub emit_segment_frames: bool, - /// Opt-in on-disk replay persistence. When `Some`, every broadcast - /// frame is appended to the store and the room is rehydrated from - /// the store on construction. Has no effect when `replay_policy == - /// ReplayTurns::Disabled` (the in-memory log is `None` so there is - /// nothing to persist or restore). - pub replay_store: Option>, -} - -pub fn spawn_room( - initial_subscriber: Subscriber, - mut agent: AgentProcess, - room_id: String, - options: RoomOptions, -) -> (RoomHandle, JoinHandle<()>) { - let (tx, rx) = mpsc::channel::(SESSION_QUEUE_CAPACITY); - let stdout_rx = agent - .take_stdout_rx() - .expect("AgentProcess::take_stdout_rx must succeed on a fresh process"); - let stderr_rx = agent - .take_stderr_rx() - .expect("AgentProcess::take_stderr_rx must succeed on a fresh process"); - - let pump_tx = tx.clone(); - let pump_room_id = room_id.clone(); - let pump = tokio::spawn(async move { - let mut rx = stdout_rx; - while let Some(line) = rx.recv().await { - if pump_tx.send(RoomMsg::AgentStdoutLine(line)).await.is_err() { - return; - } - } - let _ = pump_tx.send(RoomMsg::AgentDied).await; - tracing::debug!(room = %pump_room_id, "stdout pump finished"); - }); - - let stderr_tx = tx.clone(); - let stderr_room_id = room_id.clone(); - let stderr_pump = tokio::spawn(async move { - let mut rx = stderr_rx; - while let Some(line) = rx.recv().await { - if stderr_tx - .send(RoomMsg::AgentStderrLine(line)) - .await - .is_err() - { - return; - } - } - tracing::debug!(room = %stderr_room_id, "stderr pump finished"); - }); - - let actor = tokio::spawn(run_room( - rx, - tx.clone(), - agent, - initial_subscriber, - pump, - stderr_pump, - room_id, - options, - )); - (RoomHandle { tx }, actor) -} - -/// Reason the session loop exited. Drives the teardown sequence — agent -/// death gets a structured 1011 close to subscribers; TTL expiry and -/// natural shutdown drop subscriber senders without a close frame. -enum ExitReason { - LastSubscriberLeft, // followed TTL grace; subscribers map already empty - TtlExpired, // subscribers map still empty after grace - AgentDied, - ChannelClosed, // registry dropped tx; uncommon -} - -#[allow(clippy::too_many_arguments)] -async fn run_room( - mut rx: mpsc::Receiver, - self_tx: mpsc::Sender, - mut agent: AgentProcess, - initial_subscriber: Subscriber, - pump: JoinHandle<()>, - stderr_pump: JoinHandle<()>, - session_id: String, - options: RoomOptions, -) { - let mut inner = RoomInner::new( - session_id.clone(), - options.agent_cwd.clone(), - options.replay_policy, - options.meta_propagate, - options.client_tool_policy, - options.session_list_index.clone(), - self_tx, - options.emit_segment_frames, - options.replay_store.clone(), - ); - inner - .attach(initial_subscriber) - .expect("initial subscriber cannot collide on an empty map"); - tracing::info!(session = %session_id, subscribers = inner.subscribers.len(), "session started"); - - // None when at least one subscriber is attached; Some(sleep) while in - // TTL grace. The select! arm only fires when the sleep is armed. - let mut ttl_sleep: Option>> = None; - - let reason = loop { - let exit = tokio::select! { - biased; - msg = rx.recv() => { - match msg { - None => Some(ExitReason::ChannelClosed), - Some(RoomMsg::Attach { subscriber, ack }) => { - let result = inner.attach(subscriber); - if result.is_ok() && ttl_sleep.take().is_some() { - tracing::info!( - session = %session_id, - "TTL grace cancelled by attach", - ); - } - let _ = ack.send(result); - None - } - Some(RoomMsg::Detach { peer_id }) => { - let now_empty = inner.detach(&peer_id); - if now_empty { - tracing::info!( - session = %session_id, - ttl_secs = options.session_ttl.as_secs_f64(), - "last subscriber gone; starting TTL grace", - ); - ttl_sleep = Some(Box::pin(tokio::time::sleep(options.session_ttl))); - } - None - } - Some(RoomMsg::InboundFromSubscriber { peer_id, bytes }) => { - if let Some(out) = inner.handle_inbound(&peer_id, bytes) - && let Err(err) = agent.send(&out).await - { - tracing::warn!( - session = %session_id, - %peer_id, - error = %err, - "agent stdin write failed", - ); - } - None - } - Some(RoomMsg::AgentStdoutLine(line)) => { - let action = inner.handle_agent_line(line); - for out in action.writes_to_agent { - if let Err(err) = agent.send(&out).await { - tracing::warn!( - session = %session_id, - error = %err, - "agent stdin write failed while handling agent response/request policy", - ); - break; - } - } - if action.session_empty { - Some(ExitReason::LastSubscriberLeft) - } else { - None - } - } - Some(RoomMsg::AgentStderrLine(line)) => { - inner.handle_agent_stderr_line(line); - None - } - Some(RoomMsg::AgentDied) => { - Some(ExitReason::AgentDied) - } - Some(RoomMsg::AttachStreamBackfill(plan)) => { - inner.send_attach_backfill_page(plan); - None - } - Some(RoomMsg::Snapshot { ack }) => { - let snap = inner.build_snapshot(ttl_sleep.is_some()); - let _ = ack.send(snap); - None - } - } - } - _ = async { - match ttl_sleep.as_mut() { - Some(s) => s.as_mut().await, - None => std::future::pending::<()>().await, - } - } => { - if inner.subscribers.is_empty() { - Some(ExitReason::TtlExpired) - } else { - // Shouldn't happen — sleep was armed when no subs were - // present, attach disarms it. Defensive: clear and - // continue. - ttl_sleep = None; - None - } - } - }; - if let Some(r) = exit { - break r; - } - }; - - match reason { - ExitReason::AgentDied => { - tracing::warn!(session = %session_id, "agent subprocess exited; closing subscribers with 1011"); - inner.close_all_subscribers(WS_CLOSE_AGENT_DEAD, "agent subprocess exited"); - } - ExitReason::TtlExpired => { - tracing::info!(session = %session_id, "TTL expired; tearing down session"); - } - ExitReason::LastSubscriberLeft => { - tracing::info!(session = %session_id, "session ended (no subscribers)"); - } - ExitReason::ChannelClosed => { - tracing::info!(session = %session_id, "session ended (channel closed)"); - } - } - - inner.clear_session_list_metadata(); - inner.subscribers.clear(); - if let Err(err) = agent.shutdown(SHUTDOWN_TIMEOUT).await { - tracing::warn!(session = %session_id, error = %err, "agent shutdown error"); - } - pump.abort(); - stderr_pump.abort(); - tracing::info!(session = %session_id, "session task exiting"); -}