diff --git a/AGENTS.md b/AGENTS.md index fe2fc1d3d..34afd9b21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,52 @@ Project-specific rules and conventions for AI assistants and contributors. ## High-Priority Rules +### NEVER guess an agent CLI's behavior — only assert what an approved source proves + +Absolutely forbidden: inferring, guessing, or "reasoning about likely behavior" of any agent CLI +(claude, codex, gemini, opencode, hermes, aionrs, …) — its wire protocol, message shapes, field +semantics, timing, defaults, or capabilities — from a CLI's name, a plausible mental model, prior +training knowledge, or how you *think* it probably works. Every claim about an agent CLI's behavior +MUST be backed by one of these approved sources, cited explicitly by path: + +1. **Captured real data** — actual sampled wire traffic under + `~/aion/protocols/samples/` (e.g. `codex-cli//`, `claude-cli//`, `codex-acp/`, + `opencode/`, `capture/`). This is ground truth for what the CLI actually emitted. +2. **The ACP library source** — `agent-client-protocol` (main crate + `agent-client-protocol-schema`), + vendored at `~/.cargo/registry/src/*/agent-client-protocol-*` — for the canonical ACP wire types + and semantics we translate to. +3. **An official adapter's code** — the codex `app-server` machine-generated JSON schema under + `~/aion/protocols/samples/codex-cli//schema-full/` (ground truth from the codex binary + itself), the official claude-code / claude-code-acp adapter source, or an equivalent + first-party adapter — for inferring a CLI's contract from the reference implementation. + +Additional reliable sources, when a claim can be grounded in them: **the CLI binary's own +`--help` / self-describing schema output** (run it and read it), and **our own passing +integration/live-e2e fixtures** that were recorded against the real CLI (not hand-authored +mocks). If none of these can substantiate a claim, the honest answer is "not yet verified — +need a capture/schema", and the next step is to capture or read a source — NOT to guess. + +When you state any agent-CLI behavior, cite the source inline: `verified: samples/codex-cli/0.137.0/schema-full/ClientRequest.json` +or `verified: agent-client-protocol-schema-0.12.0/src/session.rs`. A claim with no such citation +is a guess and violates this rule. This is a non-negotiable, standing constraint — it outranks +convenience and applies to every statement, plan, commit message, and design doc. + +### Do NOT state a claim as fact until you have verified it in the code yourself + +This rule exists because of a repeated, costly failure mode: forming a confident conclusion from a *proxy* for the truth instead of the truth itself, then reporting it to the user as fact. Concrete instances that must never recur: + +- **Trusting a sub-agent's conclusion without checking its evidence.** A spawned agent reported "the frontend has NO question renderer; the break is in the frontend." That was false — the frontend renders whatever `options[]` the backend sends; the real bug was the backend hard-coding `[Allow, Reject]` options for an AskUserQuestion. The conclusion was relayed to the user verbatim. **A sub-agent's report is a lead, not a fact. Before you repeat any load-bearing claim from an agent, open the cited file:line and confirm it says what the agent said.** +- **Declaring equivalence/correctness from a thin test.** A frame-by-frame A/B was run with one trivial prompt ("Reply PONG") that only exercised `start/text/finish`, then "core turn flow is equivalent" was declared. The prompt never triggered tool-output streaming, subagents, plans, permissions, AskUserQuestion, or mode-switch — where all the real divergences were. **A green result on inputs that don't exercise the behavior is not evidence about that behavior. Before claiming a class of behavior works, confirm your test actually produces that class of event.** + +Enforced behaviors: +1. **Cite from primary source.** Any claim about what code does must be backed by a file you (this agent) have read this session — not a sub-agent's summary, not memory, not inference from names. Sub-agent findings must be spot-checked against the code before being surfaced. +2. **Verify the negative before asserting absence.** Never say "X has no Y / feature Z doesn't exist / the frontend can't do this" until you have grepped for it AND read the relevant handler. Absence is a strong claim; a single missed file falsifies it (e.g. `sideQuestion.ts`, `MessageAcpPermission` rendering dynamic `options[]`). +3. **Match the test to the claim.** When verifying behavior, the test input must exercise the exact events/paths the claim covers. Trivial/happy-path inputs prove only the trivial path. When you cannot exercise a path, say so explicitly rather than implying it passed. +4. **Trace to the break, don't guess the layer.** For a cross-layer bug (backend→wire→frontend), follow the actual data through every link and locate where it diverges from expected. Do not attribute the break to a layer by plausibility. +5. **Calibrate language to evidence.** Say "verified: ", "not yet checked", or "a sub-agent claims X (unverified)". Never launder an unverified lead into a flat assertion. + +See also the standing discipline in the root `AGENTS.md` / memory `aioncore-verification-blindspot-g6`: self-consistent-all-green ≠ correct — verify outward (against a real agent) AND against the old/reference implementation, not just against your own happy path. + ### Do NOT add fields to `AcpAgentManager` unless every alternative is exhausted `AcpAgentManager` (in `crates/aionui-ai-agent/src/acp_agent.rs`) is already large and carries multiple overlapping state holders (e.g. `runtime_snapshot`, `state`, `preferred_mode`, `config`). New fields tend to duplicate semantics that `AcpRuntimeSnapshot` or `AcpState` already model, which fragments the source of truth and makes resume/new paths diverge. diff --git a/Cargo.lock b/Cargo.lock index ef292321b..9e872d309 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,8 +347,10 @@ dependencies = [ "aionui-db", "aionui-extension", "aionui-mcp", + "aionui-process", "aionui-realtime", "aionui-runtime", + "aionui-session", "aionui-system", "aionui-team-prompts", "async-trait", @@ -391,6 +393,7 @@ dependencies = [ name = "aionui-app" version = "0.1.47" dependencies = [ + "aion-config", "aionui-ai-agent", "aionui-api-types", "aionui-assets", @@ -405,6 +408,7 @@ dependencies = [ "aionui-file", "aionui-mcp", "aionui-office", + "aionui-process", "aionui-realtime", "aionui-runtime", "aionui-shell", @@ -437,6 +441,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "uuid", "windows-sys 0.61.2", "wiremock", ] @@ -737,6 +742,25 @@ dependencies = [ "which 7.0.3", ] +[[package]] +name = "aionui-process" +version = "0.1.47" +dependencies = [ + "aionui-common", + "aionui-runtime", + "async-trait", + "fs2", + "libc", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + [[package]] name = "aionui-realtime" version = "0.1.47" @@ -778,6 +802,26 @@ dependencies = [ "zip 2.4.2", ] +[[package]] +name = "aionui-session" +version = "0.1.47" +dependencies = [ + "aionui-common", + "aionui-process", + "aionui-session", + "async-trait", + "base64", + "futures-util", + "proptest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "aionui-shell" version = "0.1.47" @@ -1502,6 +1546,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.1" @@ -4009,6 +4068,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "prost" version = "0.13.5" @@ -4032,6 +4110,12 @@ dependencies = [ "syn", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-xml" version = "0.41.0" @@ -4177,6 +4261,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4525,6 +4618,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -5717,6 +5822,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -5835,6 +5946,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index a1626286e..77e5ac671 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ members = [ "crates/aionui-api-types", "crates/aionui-realtime", "crates/aionui-runtime", + "crates/aionui-process", + "crates/aionui-session", "crates/aionui-auth", "crates/aionui-system", "crates/aionui-file", @@ -37,6 +39,8 @@ aionui-db = { path = "crates/aionui-db" } aionui-api-types = { path = "crates/aionui-api-types" } aionui-realtime = { path = "crates/aionui-realtime" } aionui-runtime = { path = "crates/aionui-runtime" } +aionui-process = { path = "crates/aionui-process" } +aionui-session = { path = "crates/aionui-session" } aionui-auth = { path = "crates/aionui-auth" } aionui-system = { path = "crates/aionui-system" } aionui-file = { path = "crates/aionui-file" } @@ -180,6 +184,7 @@ http-body-util = "0.1" # Testing tempfile = "3" wiremock = "0.6" +proptest = "1" # Random getrandom = "0.2" diff --git a/crates/aionui-ai-agent/Cargo.toml b/crates/aionui-ai-agent/Cargo.toml index 1a7a31457..dcf5335cf 100644 --- a/crates/aionui-ai-agent/Cargo.toml +++ b/crates/aionui-ai-agent/Cargo.toml @@ -14,6 +14,8 @@ aionui-runtime.workspace = true aionui-extension.workspace = true aionui-api-types.workspace = true aionui-team-prompts.workspace = true +aionui-session.workspace = true +aionui-process.workspace = true aionui-system.workspace = true axum.workspace = true base64.workspace = true diff --git a/crates/aionui-ai-agent/src/agent_task.rs b/crates/aionui-ai-agent/src/agent_task.rs index 753581288..30fc3d260 100644 --- a/crates/aionui-ai-agent/src/agent_task.rs +++ b/crates/aionui-ai-agent/src/agent_task.rs @@ -150,6 +150,10 @@ pub trait IMockAgent: IAgentTask { pub enum AgentInstance { Acp(Arc), Aionrs(Arc), + /// clean-slate direct-CLI session model (claude/codex only). Wraps an + /// `aionui_session::SessionBackend` via [`SessionAgentTask`]. Every other + /// backend keeps the `Acp` path. See the session-model-port design doc. + Session(Arc), /// Test-only trait-object escape hatch used by downstream crates /// (conversation/cron/team/app tests) to inject fake agents without /// spinning up a real CLI or WebSocket connection. Gated behind @@ -169,6 +173,7 @@ impl AgentInstance { match self { Self::Acp(m) => m.as_ref(), Self::Aionrs(m) => m.as_ref(), + Self::Session(m) => m.as_ref(), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.as_ref(), } @@ -234,6 +239,12 @@ impl AgentInstance { match self { Self::Acp(m) => m.kill_and_wait(reason), Self::Aionrs(m) => m.kill_and_wait(reason), + // Session teardown is Drop-driven (dropping the last SessionBackend + // handle aborts its reader + reaps the child). Nothing to await here. + Self::Session(m) => { + let _ = m.kill(reason); + Box::pin(std::future::ready(())) + } #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => { let _ = m.kill(reason); @@ -257,6 +268,9 @@ impl AgentInstance { match self { Self::Acp(m) => m.get_confirmations(), Self::Aionrs(m) => m.get_confirmations(), + // Session permissions surface as AcpPermission stream events + are + // answered via confirm(); no separate cached-confirmation list yet. + Self::Session(m) => m.get_confirmations(), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.get_confirmations(), } @@ -273,6 +287,7 @@ impl AgentInstance { match self { Self::Acp(m) => m.confirm(msg_id, call_id, data, always_allow), Self::Aionrs(m) => m.confirm(msg_id, call_id, data, always_allow), + Self::Session(m) => m.confirm(msg_id, call_id, data, always_allow), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.confirm(msg_id, call_id, data, always_allow), } @@ -283,6 +298,8 @@ impl AgentInstance { match self { Self::Acp(_) => false, Self::Aionrs(m) => m.check_approval(action, command_type), + // Session (claude/codex) has no aionrs-style auto-approve list. + Self::Session(_) => false, #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.check_approval(action, command_type), } @@ -291,7 +308,7 @@ impl AgentInstance { /// Session key for test doubles that expose one. pub fn get_session_key(&self) -> Option { match self { - Self::Acp(_) | Self::Aionrs(_) => None, + Self::Acp(_) | Self::Aionrs(_) | Self::Session(_) => None, #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.get_session_key(), } @@ -302,6 +319,7 @@ impl AgentInstance { match self { Self::Acp(m) => m.mode().await, Self::Aionrs(m) => m.mode().await, + Self::Session(m) => m.mode().await, #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.mode().await, } @@ -324,6 +342,7 @@ impl AgentInstance { Ok(GetModelInfoResponse { model_info }) } Self::Aionrs(_) => Ok(GetModelInfoResponse { model_info: None }), + Self::Session(m) => m.get_model().await, #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.get_model().await, } @@ -333,6 +352,7 @@ impl AgentInstance { match self { Self::Acp(m) => m.config_options().await, Self::Aionrs(m) => m.config_options().await, + Self::Session(m) => m.get_config_options().await, #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.get_config_options().await, } @@ -348,6 +368,7 @@ impl AgentInstance { match self { Self::Acp(m) => m.set_config_option_confirmed(option_id, value).await, Self::Aionrs(m) => m.set_config_option(option_id, value).await, + Self::Session(m) => m.set_config_option(option_id, value).await, #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.set_config_option(option_id, value).await, } @@ -371,6 +392,7 @@ impl AgentInstance { Ok(Some(value)) } Self::Aionrs(_) => Ok(None), + Self::Session(m) => m.get_usage().await, #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.get_usage().await, } @@ -383,6 +405,7 @@ impl AgentInstance { match self { Self::Acp(m) => m.load_slash_commands().await, Self::Aionrs(m) => m.get_slash_commands().await, + Self::Session(m) => m.get_slash_commands().await, #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.get_slash_commands().await, } @@ -413,6 +436,10 @@ impl AgentInstance { status: "unsupported".into(), answer: None, }), + Self::Session(_) => Ok(SideQuestionResponse { + status: "unsupported".into(), + answer: None, + }), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.handle_side_question(req).await, } diff --git a/crates/aionui-ai-agent/src/factory/acp.rs b/crates/aionui-ai-agent/src/factory/acp.rs index 98745c29e..e3cd605bc 100644 --- a/crates/aionui-ai-agent/src/factory/acp.rs +++ b/crates/aionui-ai-agent/src/factory/acp.rs @@ -50,6 +50,48 @@ pub(super) async fn build( config.backend.clone_from(&meta.backend); } + // Session-model port (phase 1): claude/codex run through the clean-slate + // direct-CLI SessionBackend (SessionAgentTask), NOT the ACP manager. Every other + // ACP vendor keeps the AcpAgentManager path below. Gated on a spawner being wired + // (production); absent (tests) → fall through to the ACP path. The build inputs + // mirror clean-slate `build_runtime` 1:1 (resume anchor, mode/model precedence, + // MCP + preset + skills init surface, cc-switch env, codex sandbox/approval). + if let (Some(backend_label), Some(spawner)) = (config.backend.as_deref(), deps.session_spawner.clone()) + && matches!(backend_label, "claude" | "codex") + && let Some(instance) = crate::session_agent::build_session_instance( + backend_label, + crate::session_agent::SessionBuildInputs { + conversation_id: ctx.conversation_id.clone(), + workspace: ctx.workspace.clone(), + config: &config, + metadata: &meta, + session_snapshot: build_context.session_snapshot.as_ref(), + backend_session_id: build_context.session_id.clone(), + mcp_server_repo: deps.mcp_server_repo.as_ref(), + broadcaster: deps.broadcaster.clone(), + // G5: keyed by the resolved catalog row so the discovered + // modes/models/commands refresh the `/api/agents` picker (the + // AcpAgentManager path does this via CatalogForwarder; the session + // path polls capabilities() directly since its stream carries no + // catalog events). + catalog_writeback: Some((meta.id.clone(), deps.agent_registry.catalog_sender())), + // Persist the resume anchor + observed mode/model from the session + // pump (the ACP path does this via acp_agent_service.attach, which + // this early-return bypasses). + acp_session_repo: Some(deps.acp_agent_service.repo()), + }, + spawner, + ) + .await? + { + tracing::info!( + conversation_id = %ctx.conversation_id, + backend = %backend_label, + "session-port: routing conversation through the direct-CLI SessionAgentTask (not AcpAgentManager)" + ); + return Ok(instance); + } + let mut command_spec = resolve_agent_command_spec(&meta, &ctx.workspace, &ctx.conversation_id, deps.broadcaster.clone()).await?; apply_acp_launch_policy( diff --git a/crates/aionui-ai-agent/src/factory/mod.rs b/crates/aionui-ai-agent/src/factory/mod.rs index d92428455..65efa0af2 100644 --- a/crates/aionui-ai-agent/src/factory/mod.rs +++ b/crates/aionui-ai-agent/src/factory/mod.rs @@ -40,6 +40,11 @@ pub struct AgentFactoryDeps { /// inject enabled servers into `session/new` (ELECTRON-1JG fix). /// `None` for tests/composition paths that do not need MCP injection. pub mcp_server_repo: Option>, + /// Subprocess spawner for the clean-slate session model (claude/codex go + /// through `SessionAgentTask` instead of the ACP manager — session-model + /// port phase 1). `None` on paths that never build a claude/codex session + /// (tests); when `None` those backends fall back to the ACP path. + pub session_spawner: Option>, } /// Build a production agent factory that dispatches to concrete agent types. diff --git a/crates/aionui-ai-agent/src/lib.rs b/crates/aionui-ai-agent/src/lib.rs index 3872e553c..7a4778ca5 100644 --- a/crates/aionui-ai-agent/src/lib.rs +++ b/crates/aionui-ai-agent/src/lib.rs @@ -12,12 +12,18 @@ pub mod error; pub mod factory; pub(crate) mod idle_scanner; pub mod manager; +/// Neutral MCP resolution for the session-model port (claude/codex). Ported from +/// clean-slate `aionui-agent-context::mcp_resolve` — the SSOT that turns a +/// conversation's configured MCP servers into the SDK-free `SessionMcpServer` +/// shape the `SessionBackend` stack carries in `SessionConfig.init.mcp_servers`. +pub(crate) mod mcp_resolve; pub(crate) mod persistence; pub mod protocol; pub mod registry; pub mod routes; pub(crate) mod runtime_status; pub(crate) mod services; +pub mod session_agent; pub mod session_context; pub mod shared_kernel; pub mod task_manager; diff --git a/crates/aionui-ai-agent/src/mcp_resolve.rs b/crates/aionui-ai-agent/src/mcp_resolve.rs new file mode 100644 index 000000000..82fbca3f4 --- /dev/null +++ b/crates/aionui-ai-agent/src/mcp_resolve.rs @@ -0,0 +1,175 @@ +//! Neutral MCP server resolution (Wave 0c E) — the SINGLE source of truth for +//! turning a conversation's configured MCP servers into the SDK-free +//! [`SessionMcpServer`] shape the clean-slate session stack carries in +//! `SessionConfig.init.mcp_servers`. +//! +//! The legacy per-backend resolvers (`factory::acp::load_user_mcp_servers` → +//! `Vec`, `factory::aionrs::load_user_mcp_servers` +//! → `HashMap`) emit SDK/engine-specific types. This +//! module emits the NEUTRAL `aionui_api_types::SessionMcpServer` so the app +//! boundary (`aionui-app`) can convert it once into the crate-local +//! `aionui_session::McpServerSpec`, and each backend serializes that into its own +//! wire shape. Same row-walking + selection + stdio-launch-resolution logic as +//! the legacy ACP path, but vendor-neutral. + +use std::sync::Arc; + +use aionui_api_types::{SessionMcpServer, SessionMcpTransport}; +use aionui_db::IMcpServerRepository; +use aionui_db::models::McpServerRow; +use aionui_realtime::EventBroadcaster; +use aionui_runtime::ensure_runtime_command; +use tracing::{info, warn}; + +/// Resolve a conversation's user-configured MCP servers into neutral +/// [`SessionMcpServer`]s. `selected_ids = Some` → that frozen snapshot defines the +/// session (injected regardless of the row's global `enabled` flag); `None` → all +/// enabled rows. Builtin rows are excluded (guide/team MCP are folded separately +/// by the caller). Stdio launch commands are RESOLVED here (e.g. `npx` → the +/// bundled-node absolute path) so the spec that reaches `open_session` is final — +/// the Wave 0c contract that `McpTransport::Stdio.command` is pre-resolved. +/// +/// Best-effort: a repo error, a capability-unsupported transport, or a malformed +/// `transport_config` row is warn-logged and skipped, never fatal. `broadcaster` +/// is accepted for parity with the legacy reporter path (runtime-resolution status +/// reporting) and reserved for that use. +pub async fn resolve_session_mcp_servers( + repo: &dyn IMcpServerRepository, + selected_ids: Option<&[String]>, + conversation_id: &str, + _broadcaster: Arc, +) -> Vec { + let rows_result = match selected_ids { + Some(ids) => repo.list_by_ids_any(ids).await, + None => repo.list().await, + }; + let rows = match rows_result { + Ok(r) => r, + Err(err) => { + warn!(conversation_id, error = %err, "mcp_resolve: list() failed; skipping injection"); + return Vec::new(); + } + }; + + let mut servers = Vec::with_capacity(rows.len()); + for row in rows { + let selected = selected_ids + .map(|ids| ids.iter().any(|id| id == &row.id)) + .unwrap_or(row.enabled); + if !selected || row.builtin { + continue; + } + match row_to_session_mcp_server(&row).await { + Ok(server) => servers.push(server), + Err(err) => { + warn!( + conversation_id, + server_id = %row.id, + server_name = %row.name, + error = %err, + "mcp_resolve: failed to convert row; skipping" + ); + } + } + } + + if !servers.is_empty() { + info!( + conversation_id, + count = servers.len(), + "mcp_resolve: resolved user MCP servers" + ); + } + servers +} + +/// Parse one `McpServerRow` into a neutral `SessionMcpServer`, resolving the stdio +/// launch command. Mirrors `factory::acp::row_to_sdk_mcp_server` but emits the +/// neutral type. Returns an error string when `transport_config` is malformed. +async fn row_to_session_mcp_server(row: &McpServerRow) -> Result { + let value: serde_json::Value = + serde_json::from_str(&row.transport_config).map_err(|e| format!("invalid transport_config JSON: {e}"))?; + + let transport = match row.transport_type.as_str() { + "stdio" => { + let command = value + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| "stdio: missing command".to_owned())?; + let args: Vec = value + .get("args") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + let mut env: std::collections::HashMap = value + .get("env") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned()))) + .collect() + }) + .unwrap_or_default(); + + // Resolve the launch command (npx/bun → bundled path) + fold in the + // runtime-provided args prefix + env, exactly like the legacy + // `ensure_stdio_launch`. The resolved form is what the agent spawns. + let resolved = ensure_runtime_command(command).await.map_err(|e| e.to_string())?; + let mut final_args: Vec = resolved + .args_prefix + .iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + final_args.extend(args); + for (k, v) in resolved.env { + env.insert(k.to_string_lossy().into_owned(), v.to_string_lossy().into_owned()); + } + SessionMcpTransport::Stdio { + command: resolved.program.to_string_lossy().into_owned(), + args: final_args, + env, + } + } + "http" | "streamable_http" => { + let url = value + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| "http: missing url".to_owned())? + .to_owned(); + SessionMcpTransport::StreamableHttp { + url, + headers: parse_headers(value.get("headers")), + } + } + "sse" => { + let url = value + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| "sse: missing url".to_owned())? + .to_owned(); + SessionMcpTransport::Sse { + url, + headers: parse_headers(value.get("headers")), + } + } + other => return Err(format!("unknown transport type: {other}")), + }; + + Ok(SessionMcpServer { + id: row.id.clone(), + name: row.name.clone(), + transport, + }) +} + +/// Parse a JSON headers object into a `HashMap` (string values only). +fn parse_headers(value: Option<&serde_json::Value>) -> std::collections::HashMap { + value + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned()))) + .collect() + }) + .unwrap_or_default() +} diff --git a/crates/aionui-ai-agent/src/persistence/acp_session_sync.rs b/crates/aionui-ai-agent/src/persistence/acp_session_sync.rs index be58f3f16..0d96c4bfe 100644 --- a/crates/aionui-ai-agent/src/persistence/acp_session_sync.rs +++ b/crates/aionui-ai-agent/src/persistence/acp_session_sync.rs @@ -42,6 +42,14 @@ impl AcpSessionSyncService { }) } + /// The underlying `acp_session` repository. Exposed so the session-model port's + /// `SessionAgentTask` can persist the resume anchor + observed config directly + /// from its own event pump (it bypasses the ACP manager's `attach` domain-event + /// consumer, so it writes the same rows through the same repo). + pub fn repo(&self) -> Arc { + self.repo.clone() + } + /// Read the persisted per-session state for `conversation_id`. pub async fn load_persisted(&self, conversation_id: &str) -> Option { match self.repo.load_runtime_state(conversation_id).await { diff --git a/crates/aionui-ai-agent/src/protocol/events/mod.rs b/crates/aionui-ai-agent/src/protocol/events/mod.rs index 3da500d11..01ba595dd 100644 --- a/crates/aionui-ai-agent/src/protocol/events/mod.rs +++ b/crates/aionui-ai-agent/src/protocol/events/mod.rs @@ -53,6 +53,17 @@ pub enum AgentStreamEvent { System(serde_json::Value), RequestTrace(serde_json::Value), SessionAssigned(SessionAssignedEventData), + /// Intra-turn soft boundary between two independent assistant outputs. + /// + /// Unlike `Finish`, this does NOT terminate the turn or the relay: it only + /// tells the relay to close the current text/thinking segment so the next + /// batch of text starts a fresh message bubble. Emitted by the direct-CLI + /// session pump when it suppresses a non-blocking Workflow's intermediate + /// launch `result` (claude produces one launch output while subagents run + /// and a separate completion output afterwards — two independent outputs + /// under one turn). The relay consumes it internally and never forwards it + /// to the WebSocket, so no frontend renderer is required. + SegmentBreak, } /// Data for the `Start` event. diff --git a/crates/aionui-ai-agent/src/protocol/events/tool_call.rs b/crates/aionui-ai-agent/src/protocol/events/tool_call.rs index eaf349611..7bb0f6e84 100644 --- a/crates/aionui-ai-agent/src/protocol/events/tool_call.rs +++ b/crates/aionui-ai-agent/src/protocol/events/tool_call.rs @@ -110,6 +110,10 @@ pub enum ToolCallStatus { Running, Completed, Error, + /// The turn ended (cancel/crash) while this call was still open, so no + /// terminal ToolResult will ever arrive; emitted by the fold layer to + /// close the call instead of leaving it `Running` forever. + Canceled, } /// A single entry in a `ToolGroup` event. diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs new file mode 100644 index 000000000..d5a90d2e5 --- /dev/null +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -0,0 +1,4669 @@ +//! `SessionAgentTask` — adapts the clean-slate `aionui_session::SessionBackend` +//! (direct-CLI actor model for claude/codex) to origin's `IAgentTask` contract. +//! +//! Phase 1 of the session-model port (see +//! `protocols/design/session-model-port-to-origin-plan.md`). ONLY claude and codex +//! run through this; every other backend keeps the existing `AcpAgentManager` path. +//! +//! Shape: hold the `SessionBackend`, spawn one translator task that drains its +//! `events()` (`SessionEnvelope` → `SessionEvent`) and re-broadcasts as +//! `AgentStreamEvent` on the channel `subscribe()` hands out. Commands lower to +//! `SessionBackend::dispatch`. + +use std::sync::Arc; +use std::sync::atomic::{AtomicI64, Ordering}; + +use aionui_common::{AgentKillReason, ConversationStatus, TimestampMs, now_ms}; +use aionui_session::{ + Command, CommandMeta, ContentBlock, SessionBackend, SessionEnvelope, SessionEvent, ToolResultContent, +}; +use futures_util::stream::BoxStream; +use tokio::sync::broadcast; + +use crate::agent_task::IAgentTask; +use crate::error::AgentError; +use crate::protocol::events::session_updates::AvailableCommandsEventData; +use crate::protocol::events::session_updates::ThinkingEventData; +use crate::protocol::events::tool_call::{ToolCallEventData, ToolCallStatus}; +use crate::protocol::events::{ + AgentStreamEvent, FinishEventData, StartEventData, TextEventData, TipType, TipsEventData, +}; +use crate::protocol::send_error::AgentSendError; +use crate::shared_kernel::PersistedSessionState; +use crate::types::SendMessageData; +use aionui_api_types::AcpBuildExtra; +use aionui_common::AgentType; +use aionui_db::{IAcpSessionRepository, IMcpServerRepository, SaveRuntimeStateParams}; +use aionui_realtime::EventBroadcaster; + +const EVENT_CHANNEL_CAPACITY: usize = 512; + +// Option ids for the generic tool-approval card. `confirm()` maps the incoming +// `data` string against these to pick the PermissionDecision; anything else is +// treated as an AskUserQuestion answer label (Approved + `selected`). +const PERM_ALLOW: &str = "allow"; +const PERM_ALLOW_ALWAYS: &str = "allow_always"; +const PERM_REJECT: &str = "reject"; + +/// The `config_selections` key under which a claude session's chosen reasoning-effort +/// level is persisted. claude emits NO `ConfigChanged` for effort (only mode/model), so +/// `set_config_option` persists it here directly and `build_session_instance` re-applies +/// it after open (there is no spawn-time effort flag; it rides a post-open +/// control_request). The three accepted incoming option ids (`effort`/`reasoning_effort`/ +/// `thought_level`) all normalize to this one storage key. +const EFFORT_CONFIG_KEY: &str = "effort"; + +/// Resolve the reasoning-effort catalog to surface for the effort picker, mirroring the +/// backend's `effort_is_supported` current-model precedence: the efforts of the resolved +/// current model if it can be pinned, else the union across all advertised models (so we +/// don't hide a level some selectable model supports when the current model is ambiguous / +/// not-yet-known). Empty result = no effort axis → the caller omits the option entirely. +fn resolve_current_model_efforts(models: &[aionui_session::ModelInfo], current_model: Option<&str>) -> Vec { + if let Some(model) = current_model.and_then(|id| models.iter().find(|m| m.id == id)) { + return model.reasoning_efforts.clone(); + } + let mut union: Vec = Vec::new(); + for m in models { + for e in &m.reasoning_efforts { + if !union.contains(e) { + union.push(e.clone()); + } + } + } + union +} + +/// Shared, cheaply-cloneable runtime state for a session task: the broadcast sender +/// the translator writes and `subscribe()` reads, plus liveness bookkeeping. +struct SessionRuntime { + tx: broadcast::Sender, + last_activity_ms: AtomicI64, + /// Coarse status derived from the FSM edge the translator observes. + status: std::sync::Mutex>, + /// The CLI-assigned backend session id, learned from `BackendBound`. The ACP + /// path stamps every Start/Finish with its session id; we mirror that so the + /// frontend + resume-anchor consumer see the same id. `None` until the backend + /// binds (first turn); a resume seeds it via the first BackendBound echo. + session_id: std::sync::Mutex>, + /// Optimistic mode/model selections set via `set_config_option`. The frontend's + /// `hasObservedValue` contract requires set_config_option to return + /// `confirmation: Observed` AND the option's `current_value == requested` — but + /// claude's `capabilities()` does NOT reflect an in-band switch synchronously + /// (set_model has NO confirmation wire at all; set_permission_mode confirms only + /// asynchronously via a later `system/status`). So we cache the requested value + /// here at dispatch time and have `get_config_options`/`mode`/`get_model` prefer + /// it over the (stale) capabilities snapshot — the same optimistic-override the + /// clean-slate runtime applies. Cleared/overwritten on the next switch. + mode_override: std::sync::Mutex>, + model_override: std::sync::Mutex>, + /// Optimistic reasoning-effort ("thought level") selection, symmetric with + /// mode/model. claude emits NO `ConfigChanged`/echo for effort (unlike model/mode), + /// so the streaming catalog push — which runs in the backend-Arc-free event pump and + /// cannot read `capabilities().current_effort` — reads the highlight from here. REST + /// (`get_config_options`) prefers this over the (synchronously-seeded) caps value so + /// the observed re-read confirms the switch. `None` until the user picks a level. + effort_override: std::sync::Mutex>, +} + +impl SessionRuntime { + fn touch(&self) { + self.last_activity_ms.store(now_ms(), Ordering::Relaxed); + } + fn set_status(&self, s: ConversationStatus) { + if let Ok(mut g) = self.status.lock() { + *g = Some(s); + } + } + fn set_session_id(&self, id: String) { + if let Ok(mut g) = self.session_id.lock() { + *g = Some(id); + } + } + fn session_id(&self) -> Option { + self.session_id.lock().ok().and_then(|g| g.clone()) + } + fn set_mode_override(&self, mode: String) { + if let Ok(mut g) = self.mode_override.lock() { + *g = Some(mode); + } + } + fn mode_override(&self) -> Option { + self.mode_override.lock().ok().and_then(|g| g.clone()) + } + fn set_model_override(&self, model: String) { + if let Ok(mut g) = self.model_override.lock() { + *g = Some(model); + } + } + fn model_override(&self) -> Option { + self.model_override.lock().ok().and_then(|g| g.clone()) + } + fn set_effort_override(&self, effort: String) { + if let Ok(mut g) = self.effort_override.lock() { + *g = Some(effort); + } + } + fn effort_override(&self) -> Option { + self.effort_override.lock().ok().and_then(|g| g.clone()) + } +} + +/// Cold-start catalog snapshot extracted from a persisted `agent_metadata` +/// handshake, in the SAME `aionui_session` shape the getters read off live +/// `capabilities()` — so serving the preload is a drop-in fallback with no shape +/// translation at read time. Empty vectors + `None` currents = nothing persisted. +#[derive(Default, Clone)] +struct CatalogPreload { + available_models: Vec, + current_model: Option, + available_modes: Vec, + current_mode: Option, +} + +impl CatalogPreload { + /// Parse the persisted handshake's `available_models` / `available_modes` + /// columns into the live-capabilities shape. Reuses the ACP path's + /// `extract_models_from_value` / `extract_modes_from_value` (the same + /// multi-shape parser that accepts both the `{available_models:[{id,label}]}` + /// column shape `spawn_catalog_writeback` persists AND a live-claude handshake), + /// so the two paths stay byte-compatible. `reasoning_efforts` is intentionally + /// dropped: the handshake catalog does not carry per-model efforts, and the + /// getters this feeds do not surface efforts. + fn from_handshake(handshake: &aionui_api_types::AgentHandshake) -> Self { + use crate::manager::acp::config_option_catalog::{extract_models_from_value, extract_modes_from_value}; + let (available_models, current_model) = handshake + .available_models + .as_ref() + .and_then(extract_models_from_value) + .map(|state| { + let models = state + .available_models + .iter() + .map(|m| aionui_session::ModelInfo { + id: m.model_id.to_string(), + name: m.name.clone(), + description: m.description.clone(), + reasoning_efforts: Vec::new(), + }) + .collect::>(); + let current = state.current_model_id.to_string(); + (models, (!current.is_empty()).then_some(current)) + }) + .unwrap_or_default(); + let (available_modes, current_mode) = handshake + .available_modes + .as_ref() + .and_then(extract_modes_from_value) + .map(|state| { + let modes = state + .available_modes + .iter() + .map(|m| aionui_session::ModeInfo { + id: m.id.to_string(), + name: m.name.clone(), + description: m.description.clone(), + }) + .collect::>(); + let current = state.current_mode_id.to_string(); + (modes, (!current.is_empty()).then_some(current)) + }) + .unwrap_or_default(); + Self { + available_models, + current_model, + available_modes, + current_mode, + } + } +} + +/// One claude/codex session, presented as an `IAgentTask`. +pub struct SessionAgentTask { + agent_type: AgentType, + conversation_id: String, + workspace: String, + backend: Arc, + runtime: Arc, + /// The `acp_session` persistence sink, retained so `set_config_option` can persist + /// the chosen EFFORT level into `config_selections` — claude does NOT emit a + /// `ConfigChanged` for effort (only for mode/model), so the event-pump's + /// `persist_side_effects` never sees it. Without this write, effort would be lost + /// across a respawn/resume (unlike mode/model, which persist via ConfigChanged). + /// `None` (tests) = no persistence. Shared with the pump (same Arc). + session_repo: Option>, + /// Cold-start catalog preload parsed from the persisted `agent_metadata` + /// handshake (what a PRIOR session discovered and `spawn_catalog_writeback` + /// stored). The backend's live `capabilities()` is empty until the initialize + /// round-trip lands (~seconds on resume); the mode/model getters serve this in + /// the meantime so the `/api/agents` picker is populated immediately instead of + /// blank, then the live catalog overwrites it the moment it arrives. Empty on + /// paths with no persisted catalog (fresh agent, tests). Mirrors the ACP path's + /// `preload_advertised_catalogs` "fill-when-empty, live-overwrites" semantics. + catalog_preload: CatalogPreload, + /// Command-id counter for `CommandMeta` (dispatch correlation). + command_seq: AtomicI64, +} + +impl SessionAgentTask { + /// Build a task around an already-opened `SessionBackend` and start the + /// event-translation pump. `agent_type` is `AgentType::Acp` for claude/codex + /// (they present as the ACP family to the rest of the app). + /// + /// `session_repo`, when present, is the persistence sink the event pump writes + /// on the SAME signals the legacy ACP path persisted via + /// `AcpSessionSyncService` (which this direct-CLI path bypasses): `BackendBound` + /// → `acp_session.session_id` (the resume anchor `build_session_instance` reads + /// back), `ConfigChanged` → `current_mode_id`/`current_model_id` (the mode/model + /// precedence source). `None` (tests) = no persistence. + pub fn new( + agent_type: AgentType, + conversation_id: String, + workspace: String, + backend: Arc, + session_repo: Option>, + ) -> Arc { + Self::build( + agent_type, + conversation_id, + workspace, + backend, + session_repo, + CatalogPreload::default(), + ) + } + + /// Same as [`new`], plus a cold-start catalog preload parsed from the + /// persisted `agent_metadata` handshake. Production resume path uses this so + /// the model/mode picker is populated immediately from the last discovered + /// catalog while the backend's live `capabilities()` is still empty (the + /// initialize round-trip lands a beat later and overwrites it). + #[allow(clippy::too_many_arguments)] + pub fn new_with_preload( + agent_type: AgentType, + conversation_id: String, + workspace: String, + backend: Arc, + session_repo: Option>, + handshake: &aionui_api_types::AgentHandshake, + ) -> Arc { + Self::build( + agent_type, + conversation_id, + workspace, + backend, + session_repo, + CatalogPreload::from_handshake(handshake), + ) + } + + fn build( + agent_type: AgentType, + conversation_id: String, + workspace: String, + backend: Arc, + session_repo: Option>, + catalog_preload: CatalogPreload, + ) -> Arc { + let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY); + let runtime = Arc::new(SessionRuntime { + tx, + last_activity_ms: AtomicI64::new(now_ms()), + status: std::sync::Mutex::new(None), + session_id: std::sync::Mutex::new(None), + mode_override: std::sync::Mutex::new(None), + model_override: std::sync::Mutex::new(None), + effort_override: std::sync::Mutex::new(None), + }); + // Subscribe to the backend's event stream HERE (sync), then hand ONLY the + // stream to the pump — never a backend Arc (see `spawn_event_pump` for why + // capturing a backend Arc there would leak the child process). + let events = backend.events(); + spawn_event_pump(events, runtime.clone(), conversation_id.clone(), session_repo.clone()); + Arc::new(Self { + agent_type, + conversation_id, + workspace, + backend, + runtime, + session_repo, + catalog_preload, + command_seq: AtomicI64::new(0), + }) + } + + fn next_command_id(&self) -> u64 { + self.command_seq.fetch_add(1, Ordering::Relaxed) as u64 + } + + // ── enum-level helpers forwarded from AgentInstance::Session ────────── + // Backed by the backend's cheap sync `capabilities()` snapshot (reflects + // late model/mode/config discovery) and `dispatch` for mutations. + + /// Pending confirmations, projected from the backend's live + /// `pending_permission_requests()`. The REST `/confirmations` recovery path + /// (frontend `usePendingConfirmationsRecovery`) calls this on mount/reconnect to + /// rebuild permission cards that were raised while the page was away — WITHOUT + /// this returning them, a mid-turn permission (or AskUserQuestion) raised before + /// the client subscribed is lost and the turn hangs forever waiting for an answer + /// that can never be given. The card id == call_id == request_id, matching the + /// live `AcpPermission` frame so a duplicate live+recovered pair de-dups. Options + /// mirror the live translation: AskUserQuestion → its question options, else the + /// generic allow/deny. + pub fn get_confirmations(&self) -> Vec { + self.backend + .pending_permission_requests() + .into_iter() + .map(|p| { + let is_ask = p.tool_name == "AskUserQuestion"; + let options = if is_ask { + ask_user_question_options(p.questions.as_ref()) + } else { + Vec::new() + }; + let options = if options.is_empty() { + default_permission_options() + } else { + options + }; + aionui_common::Confirmation { + id: p.request_id.clone(), + call_id: p.request_id, + title: (!p.tool_name.is_empty()).then(|| p.tool_name.clone()), + action: None, + description: String::new(), + command_type: None, + options: options + .into_iter() + .map(|o| aionui_common::ConfirmationOption { + label: o.name, + value: serde_json::Value::String(o.option_id), + params: None, + }) + .collect(), + } + }) + .collect() + } + + /// Answer a pending permission. `data` is the option the user picked (the card + /// echoes the option's `option_id` — a string, or `{option_id|value}` object). + /// The picked id maps to the answer: + /// - `reject` → Denied + /// - `allow_always` → AllowAlways + /// - `allow` → Approved + /// - anything else → an AskUserQuestion answer LABEL → Approved + `selected` + /// (claude keys the AskUserQuestion answer by the chosen label — see + /// claude_conn `build_control_response`; single-select single-question path). + /// + /// `always_allow` (legacy flag) forces AllowAlways regardless. + pub fn confirm( + &self, + _msg_id: &str, + call_id: &str, + data: serde_json::Value, + always_allow: bool, + ) -> Result<(), AgentError> { + use aionui_session::PermissionDecision; + let picked = confirm_option_id(&data); + let (decision, selected) = if always_allow { + (PermissionDecision::AllowAlways, None) + } else { + match picked.as_deref() { + Some(PERM_REJECT) => (PermissionDecision::Denied, None), + Some(PERM_ALLOW_ALWAYS) => (PermissionDecision::AllowAlways, None), + Some(PERM_ALLOW) | None => (PermissionDecision::Approved, None), + // A question answer label (AskUserQuestion): approve and forward the + // label so claude records it as the chosen answer. + Some(label) => (PermissionDecision::Approved, Some(label.to_owned())), + } + }; + let backend = self.backend.clone(); + let request_id = call_id.to_string(); + // dispatch is async; confirm() is sync in IAgentTask's sibling API, so + // fire-and-forget on the runtime (the answer rides the stdin FIFO). + tokio::spawn(async move { + let _ = backend + .dispatch(Command::AnswerPermission { + request_id, + decision, + selected, + answers: Vec::new(), + }) + .await; + }); + Ok(()) + } + + /// Resolve the catalog to serve: the backend's LIVE `capabilities()` when it has + /// discovered models/modes, else the cold-start `catalog_preload` (last session's + /// persisted handshake). The LIST and the CURRENT of each axis fall back + /// INDEPENDENTLY — this matters at cold-start: the backend seeds `caps.current_model` + /// /`caps.current_mode` from the RESOLVED snapshot at spawn (claude_conn spawn: + /// `caps.current_model = config.model`, where config.model came from the persisted + /// `current_model_id` column), so it is already the user's last interactive switch + /// even before the initialize round-trip lands and fills `available_models`. The + /// preload's current, by contrast, is frozen at the PRIOR session's write-back + /// (`spawn_catalog_writeback` runs once at open, not on a mid-turn switch), so a + /// list-emptiness-gated fallback would show a stale model in the pre-init window + /// whenever the user switched mid-turn last session (backend runs the right model, + /// picker briefly showed the old one). So: the LIST prefers live (non-empty) then + /// preload; the CURRENT prefers `caps` (already snapshot-seeded) then preload. + /// `None`-current + no preload = None (getters then lean on the runtime override). + fn effective_catalog( + &self, + ) -> ( + Vec, + Option, + Vec, + Option, + ) { + let caps = self.backend.capabilities(); + let models = if caps.available_models.is_empty() { + self.catalog_preload.available_models.clone() + } else { + caps.available_models + }; + let current_model = caps + .current_model + .or_else(|| self.catalog_preload.current_model.clone()); + let modes = if caps.available_modes.is_empty() { + self.catalog_preload.available_modes.clone() + } else { + caps.available_modes + }; + let current_mode = caps.current_mode.or_else(|| self.catalog_preload.current_mode.clone()); + (models, current_model, modes, current_mode) + } + + /// Current mode: the optimistic override (last `set_config_option("mode")`) wins + /// over the capabilities snapshot, which lags an in-band switch. + pub async fn mode(&self) -> Result { + let caps = self.backend.capabilities(); + // Preload fallback: on cold-start resume the live catalog is empty until the + // initialize round-trip lands, so serve the last-discovered current_mode + // until then (override still wins; live current_mode overwrites once present). + let current_mode = caps.current_mode.or_else(|| self.catalog_preload.current_mode.clone()); + Ok(aionui_api_types::AgentModeResponse { + mode: self.runtime.mode_override().or(current_mode).unwrap_or_default(), + initialized: true, + }) + } + + /// Current model + catalog. The optimistic override (last set_config_option + /// "model") wins over the capabilities snapshot (claude gives set_model no + /// confirmation wire, so caps.current_model never reflects the switch). + pub async fn get_model(&self) -> Result { + // Live catalog wins; cold-start resume falls back to the persisted-handshake + // preload so the picker is populated before the initialize round-trip lands. + let (models, current_model, _modes, _mode) = self.effective_catalog(); + let override_model = self.runtime.model_override(); + if models.is_empty() && current_model.is_none() && override_model.is_none() { + return Ok(aionui_api_types::GetModelInfoResponse { model_info: None }); + } + let available_models: Vec = models + .iter() + .map(|m| aionui_api_types::ModelInfoEntry { + id: m.id.clone(), + label: m.name.clone(), + }) + .collect(); + let current_id = override_model.or(current_model); + let current_label = current_id + .as_ref() + .and_then(|id| available_models.iter().find(|e| &e.id == id).map(|e| e.label.clone())); + Ok(aionui_api_types::GetModelInfoResponse { + model_info: Some(aionui_api_types::ModelInfoPayload { + current_model_id: current_id.clone(), + current_model_label: current_label.or(current_id), + available_models, + }), + }) + } + + /// Config-options (mode + model selects). For each select the optimistic override + /// (last set_config_option) wins over the capabilities snapshot's current_value — + /// this is what makes set_config_option's observed re-read succeed (the snapshot + /// lags an in-band claude switch). + pub async fn get_config_options(&self) -> Result { + // Live catalog wins; cold-start resume falls back to the persisted-handshake + // preload (per-axis) so the picker renders before the initialize round-trip lands. + let (models, current_model, modes, current_mode) = self.effective_catalog(); + // The effort catalog depends on the EFFECTIVE current model (override wins over the + // snapshot's current_model), resolved before the model option consumes it below. + let effective_model = self.runtime.model_override().or_else(|| current_model.clone()); + let mut config_options = Vec::new(); + if !modes.is_empty() { + config_options.push(aionui_api_types::AcpConfigOptionDto { + id: "mode".into(), + name: Some("Mode".into()), + label: None, + description: None, + category: Some("mode".into()), + option_type: "select".into(), + current_value: self.runtime.mode_override().or(current_mode), + options: modes + .iter() + .map(|m| aionui_api_types::AcpConfigSelectOptionDto { + value: m.id.clone(), + name: Some(m.name.clone()), + label: None, + description: m.description.clone(), + }) + .collect(), + }); + } + if !models.is_empty() { + config_options.push(aionui_api_types::AcpConfigOptionDto { + id: "model".into(), + name: Some("Model".into()), + label: None, + description: None, + category: Some("model".into()), + option_type: "select".into(), + current_value: self.runtime.model_override().or(current_model), + options: models + .iter() + .map(|m| aionui_api_types::AcpConfigSelectOptionDto { + value: m.id.clone(), + name: Some(m.name.clone()), + label: None, + description: m.description.clone(), + }) + .collect(), + }); + } + // Reasoning-effort ("thought level") axis — the direct-CLI analogue of the ACP + // path's `thought_level` config option (category-keyed so AionUi's + // `deriveSelectOption(..., 'thought_level', ['reasoning_effort'])` lights the + // picker's effort group). Only claude advertises per-model `supportedEffortLevels`; + // the option is emitted only when the resolved current model actually offers + // efforts. `current_value` prefers the optimistic override (claude emits no echo + // for effort) then the backend's synchronously-seeded `current_effort`. + let caps = self.backend.capabilities(); + let efforts = resolve_current_model_efforts(&models, effective_model.as_deref()); + if !efforts.is_empty() { + config_options.push(aionui_api_types::AcpConfigOptionDto { + id: "reasoning_effort".into(), + name: Some("Thinking".into()), + label: None, + description: None, + category: Some("thought_level".into()), + option_type: "select".into(), + current_value: self.runtime.effort_override().or(caps.current_effort), + options: efforts + .iter() + .map(|e| aionui_api_types::AcpConfigSelectOptionDto { + value: e.clone(), + name: Some(e.clone()), + label: None, + description: None, + }) + .collect(), + }); + } + Ok(aionui_api_types::GetConfigOptionsResponse { config_options }) + } + + /// Apply a config option (mode/model/other) via dispatch. + pub async fn set_config_option( + &self, + option_id: &str, + value: &str, + ) -> Result { + // Validate a runtime mode/model switch against the advertised catalog BEFORE + // dispatch — the ACP `clear_invalid_desired_*` semantic, but as REJECT+report + // (not silent-drop) since this is an explicit user action at the single runtime + // chokepoint. An EMPTY / not-yet-discovered catalog is permissive (matches ACP + // `is_mode_valid`/`is_model_valid`: an absent catalog cannot invalidate — the + // capabilities snapshot may simply not have the list yet). Only a NON-empty + // catalog that omits `value` rejects. Other option ids (effort/thought_level) + // are validated by the backend itself (claude effort catalog check). + let caps = self.backend.capabilities(); + // A NON-empty catalog that omits `value` is the only rejection case (empty + // catalog = permissive, per the comment above). `known` = catalog carries value. + let invalid = |catalog_has_value: bool, catalog_empty: bool| !catalog_empty && !catalog_has_value; + match option_id { + "mode" + if invalid( + caps.available_modes.iter().any(|m| m.id == value), + caps.available_modes.is_empty(), + ) => + { + return Err(AgentError::bad_request(format!( + "mode '{value}' is not one of the available modes" + ))); + } + "model" + if invalid( + caps.available_models.iter().any(|m| m.id == value), + caps.available_models.is_empty(), + ) => + { + return Err(AgentError::bad_request(format!( + "model '{value}' is not one of the available models" + ))); + } + _ => {} + } + let cmd = match option_id { + "mode" => Command::SetMode { + mode: value.to_string(), + }, + "model" => Command::SetModel { + model: value.to_string(), + }, + other => Command::SetConfigOption { + option_id: other.to_string(), + value: value.to_string(), + }, + }; + self.backend + .dispatch(cmd) + .await + .map_err(|e| AgentError::bad_request(e.to_string()))?; + // Cache the requested value as an optimistic override for mode/model, then + // re-read the config-options snapshot so the response satisfies the frontend's + // `hasObservedValue` contract (confirmation == Observed AND the option's + // current_value == requested). This is required because claude's own + // `capabilities()` does NOT reflect an in-band switch synchronously (set_model + // has no confirmation wire; set_permission_mode confirms only via a later + // async system/status), so without the override the option would never read + // back as observed and the frontend would reject the switch as `command_ack`. + // Mirrors the clean-slate runtime's optimistic override + observed re-read. + // effort/thought_level is now a surfaced picker option too (id `reasoning_effort`, + // category `thought_level`), so it also caches an override + falls through to the + // observed re-read — the frontend's `hasObservedValue` requires Observed AND the + // option's current_value == requested, same as mode/model. + match option_id { + "mode" => self.runtime.set_mode_override(value.to_string()), + "model" => self.runtime.set_model_override(value.to_string()), + "effort" | "reasoning_effort" | "thought_level" => { + // Optimistic highlight: claude emits no effort echo, so the streaming + // catalog push reads the current level from this override. + self.runtime.set_effort_override(value.to_string()); + // Persist the chosen effort into `config_selections` so it survives a + // respawn/resume. Unlike mode/model (persisted by the pump on + // ConfigChanged), claude emits no ConfigChanged for effort, so this is + // the ONLY place the choice is durably recorded. Backend already accepted + // + validated it (dispatch above); best-effort persist (a DB failure must + // not fail the switch the CLI already applied). + self.persist_effort(value).await; + } + _ => { + return Ok(aionui_api_types::SetConfigOptionResponse { + confirmation: aionui_api_types::ConfigOptionConfirmation::CommandAck, + config_options: None, + }); + } + } + let snapshot = self.get_config_options().await?; + // Effort is emitted under the canonical id `reasoning_effort` (category + // `thought_level`); a caller may address it via any of its aliases, so match by + // category for the effort axis and by id otherwise. + let is_effort_alias = matches!(option_id, "effort" | "reasoning_effort" | "thought_level"); + let observed = snapshot + .config_options + .iter() + .find(|o| { + if is_effort_alias { + o.category.as_deref() == Some("thought_level") + } else { + o.id == option_id + } + }) + .and_then(|o| o.current_value.as_deref()) + == Some(value); + Ok(aionui_api_types::SetConfigOptionResponse { + confirmation: if observed { + aionui_api_types::ConfigOptionConfirmation::Observed + } else { + aionui_api_types::ConfigOptionConfirmation::CommandAck + }, + config_options: Some(snapshot.config_options), + }) + } + + /// Persist the chosen effort level into `acp_session.config_selections` (under + /// [`EFFORT_CONFIG_KEY`]) so it survives a respawn/resume. Reads the existing + /// selections first and MERGES (rather than overwriting the whole map) so any other + /// future config key is preserved. Best-effort: a repo miss/failure is logged, not + /// propagated — the backend already applied the effort, and losing only the + /// persistence (not the live switch) is the safe degradation. No-op without a repo. + async fn persist_effort(&self, value: &str) { + let Some(repo) = self.session_repo.as_ref() else { + return; + }; + // Merge into the existing selection map (preserve unrelated keys). + let mut selections: std::collections::HashMap = match repo + .load_runtime_state(&self.conversation_id) + .await + { + Ok(Some(state)) => state + .config_selections_json + .as_deref() + .and_then(|raw| serde_json::from_str(raw).ok()) + .unwrap_or_default(), + Ok(None) => std::collections::HashMap::new(), + Err(err) => { + tracing::warn!(conversation_id = %self.conversation_id, error = %err, "persist_effort: load_runtime_state failed; skipping effort persist"); + return; + } + }; + selections.insert(EFFORT_CONFIG_KEY.to_owned(), value.to_owned()); + let json = match serde_json::to_string(&selections) { + Ok(j) => j, + Err(err) => { + tracing::warn!(conversation_id = %self.conversation_id, error = %err, "persist_effort: encode config_selections failed"); + return; + } + }; + let params = SaveRuntimeStateParams { + config_selections_json: Some(Some(&json)), + ..Default::default() + }; + if let Err(err) = repo.save_runtime_state(&self.conversation_id, ¶ms).await { + tracing::warn!(conversation_id = %self.conversation_id, error = %err, "persist_effort: save_runtime_state failed"); + } + } + + /// Session usage snapshot. Not tracked on the capabilities snapshot yet; + /// usage rides the `UsageDelta` stream event. Return None for now. + pub async fn get_usage(&self) -> Result, AgentError> { + Ok(None) + } + + /// Slash commands from the live capabilities snapshot. + pub async fn get_slash_commands(&self) -> Result, AgentError> { + let caps = self.backend.capabilities(); + Ok(caps + .slash_commands + .iter() + .map(|c| aionui_api_types::SlashCommandItem { + command: c.name.clone(), + description: c.description.clone().unwrap_or_default(), + completion_behavior: None, + empty_turn_tip_code: None, + empty_turn_tip_params: None, + }) + .collect()) + } +} + +#[async_trait::async_trait] +impl IAgentTask for SessionAgentTask { + fn agent_type(&self) -> AgentType { + self.agent_type + } + + fn conversation_id(&self) -> &str { + &self.conversation_id + } + + fn workspace(&self) -> &str { + &self.workspace + } + + fn status(&self) -> Option { + self.runtime.status.lock().ok().and_then(|g| *g) + } + + fn last_activity_at(&self) -> TimestampMs { + self.runtime.last_activity_ms.load(Ordering::Relaxed) + } + + fn subscribe(&self) -> broadcast::Receiver { + self.runtime.tx.subscribe() + } + + async fn send_message(&self, data: SendMessageData) -> Result<(), AgentSendError> { + self.runtime.touch(); + let mut content: Vec = Vec::new(); + if !data.content.is_empty() { + content.push(ContentBlock::Text(data.content)); + } + for path in data.files { + // File paths ride as resource links; the claude/codex adapters resolve + // them (Read tool / base64) at dispatch time. + content.push(ContentBlock::ResourceLink { + uri: path, + mime_type: None, + }); + } + let cmd = Command::Send { + content, + metadata: CommandMeta { + command_id: self.next_command_id(), + cwd: None, + extra_args: Vec::new(), + client_msg_id: Some(data.msg_id), + }, + }; + // Emit the turn-start lifecycle frame BEFORE dispatch, exactly like the ACP + // path (agent_session_flow.rs emits Start{session_id} right before prompt()). + // The backend's own turn-start signal (claude/codex PromptAccepted) arrives + // AFTER the first text delta, so it cannot drive an at-the-front Start — the + // send call is the correct, ordering-stable anchor. session_id is None on the + // very first turn (backend not yet bound) and filled on every subsequent turn. + let _ = self.runtime.tx.send(AgentStreamEvent::Start(StartEventData { + session_id: self.runtime.session_id(), + })); + self.runtime.set_status(ConversationStatus::Running); + self.backend + .dispatch(cmd) + .await + .map(|_receipt| ()) + .map_err(|e| AgentSendError::from_agent_error(AgentError::bad_gateway(e.to_string()))) + } + + async fn cancel(&self) -> Result<(), AgentError> { + self.runtime.touch(); + self.backend + .dispatch(Command::Cancel { + target: aionui_session::CancelTarget::Turn, + }) + .await + .map(|_| ()) + .map_err(|e| AgentError::internal(e.to_string())) + } + + fn kill(&self, _reason: Option) -> Result<(), AgentError> { + // Teardown is Drop-driven, by design. The `SessionBackend` trait exposes no + // close/shutdown command and no `wait_for_exit` (a crash flows out as + // `Detached`); the sole reaper is `Drop for ClaudeSessionBackend` / + // `CodexSessionBackend`, which aborts the reader and `kill_on_drop`s the + // child once the last `Arc` is released. + // + // Both manager teardown paths — `TaskManager::kill` and `kill_and_wait` — + // FIRST `tasks.remove(conversation_id)`, dropping the `Arc` + // and hence this `SessionAgentTask` and its `backend` field. Because the + // event pump no longer captures a backend Arc (see `spawn_event_pump`), + // `self.backend` is the ONLY long-lived strong handle, so that removal drops + // the last Arc and fires the backend's `Drop`. This method therefore has no + // synchronous work to do — calling `close_session` here is impossible (we + // hold the session actor, not its `BackendConnection`) and unnecessary. + Ok(()) + } +} + +/// Open a claude/codex `SessionBackend` via the clean-slate connection and wrap it +/// as an `AgentInstance::Session`. Called from the ACP factory when the resolved +/// backend is claude/codex and a spawner is available. `backend_label` is the +/// authoritative vendor ("claude"/"codex"); other labels return `None` so the caller +/// falls back to the ACP manager path. +/// Everything the caller (`factory::acp::build`) already resolved and that the +/// session assembly needs. Bundled so `build_session_instance` is the SINGLE +/// place that maps an ACP build request → the clean-slate `SessionSpec`/ +/// `SessionConfig`, mirroring clean-slate's `build_runtime` (spec_and_config + +/// resolve_session_init + the per-backend spawn_env/sandbox/approval seams). Every +/// field here has a 1:1 counterpart in that path. +pub struct SessionBuildInputs<'a> { + /// The conversation this session belongs to (the clean-slate `session_id`). + pub conversation_id: String, + /// The resolved workspace path (`SessionConfig.cwd`). + pub workspace: String, + /// The conversation's persisted build `extra` (mode/model/mcp/preset/skills). + pub config: &'a AcpBuildExtra, + /// The resolved catalog row. Used to normalize the persisted/requested mode + /// alias (`yolo`/`yoloNoSandbox` → the row's `yolo_id`; codex `default`/`autoEdit` + /// → `auto`) into the backend-native mode id, exactly as the ACP path does via + /// `initial_mode_from_params`. Without this a conversation persisted with a + /// generic alias resumes by handing the raw alias to the backend (claude rejects + /// an unknown permission-mode id; codex gets a non-native mode → wrong policy). + pub metadata: &'a aionui_api_types::AgentMetadata, + /// The persisted runtime snapshot, when present. Its `current_mode_id` / + /// `current_model_id` are the interactive-switch-persisted selections and take + /// precedence over the create-time `config` values — the same precedence + /// clean-slate's `spec_and_config` applies (`current_mode_id` ⟶ `session_mode`). + pub session_snapshot: Option<&'a PersistedSessionState>, + /// The CLI-assigned backend session id anchor. `Some` ⇒ `SessionSpec::Resume` + /// (the same signal clean-slate's `spec_and_config` uses); `None` ⇒ `Fresh`. + pub backend_session_id: Option, + /// User-configured MCP server repository (feature ELECTRON-1JG). `None` on + /// paths that never inject MCP (tests) ⇒ no injection. + pub mcp_server_repo: Option<&'a Arc>, + /// Broadcaster forwarded to the MCP resolver for runtime-resolution reporting + /// parity with the legacy ACP path. + pub broadcaster: Arc, + /// The resolved catalog row id + the registry's catalog sender, used to write + /// the backend's discovered modes/models/commands back into `agent_metadata` + /// (GAP #7 / G5) so the `/api/agents` picker stays fresh. `None` on paths that + /// have no catalog row to refresh. + pub catalog_writeback: Option<(String, crate::registry::CatalogSender)>, + /// The `acp_session` persistence sink. The event pump writes the resume anchor + /// (`BackendBound` → `session_id`) + observed mode/model (`ConfigChanged`) here — + /// the writes the legacy ACP path performed via `AcpSessionSyncService`, which + /// this direct-CLI path bypasses. `None` (tests) = no persistence. + pub acp_session_repo: Option>, +} + +/// The pure spec + mode/model mapping — the sibling of clean-slate's +/// `spec_and_config`. Extracted from `build_session_instance` so it is unit-testable +/// without spawning a backend. +/// +/// - Resume when the row carries a `backend_session_id` anchor, else Fresh (both key +/// on the conversation id). +/// - `mode`: the interactive-switch-persisted `snapshot.current_mode_id` wins over the +/// create-time `config.session_mode`; empty-filtered; NO default minted (each backend +/// safe-defaults). +/// - `model`: symmetric — `snapshot.current_model_id` wins over `config.current_model_id`. +/// A BARE runtime model id (never the JSON `ProviderWithModel` blob — clean-slate #7). +fn spec_mode_model( + conversation_id: &str, + backend_session_id: Option, + config: &AcpBuildExtra, + session_snapshot: Option<&PersistedSessionState>, + metadata: &aionui_api_types::AgentMetadata, +) -> (aionui_session::SessionSpec, Option, Option) { + use aionui_session::SessionSpec; + let spec = match &backend_session_id { + Some(_) => SessionSpec::Resume { + session_id: conversation_id.to_owned(), + backend_session_id, + }, + None => SessionSpec::Fresh { + session_id: conversation_id.to_owned(), + }, + }; + // Normalize the resolved mode alias into the backend-native id — the SAME + // transform the ACP path applies in `initial_mode_from_params`. AionUi persists + // generic aliases (`yolo`/`yoloNoSandbox`; codex `default`/`autoEdit`); handing + // those raw to the backend on resume rejects (claude unknown permission-mode) or + // mis-policies (codex non-native mode). `normalize_requested_mode` maps them via + // the catalog row's `yolo_id` / backend label; a mode without an alias passes + // through unchanged. Runs BEFORE the codex sandbox/approval derivation downstream + // (which matches both the alias and the native id, so ordering is safe). + let mode = session_snapshot + .and_then(|s| s.current_mode_id.as_ref().map(|m| m.as_str().to_owned())) + .or_else(|| config.session_mode.clone()) + .map(|m| crate::manager::acp::mode_normalize::normalize_requested_mode(metadata, &m)) + .filter(|s| !s.is_empty()); + let model = session_snapshot + .and_then(|s| s.current_model_id.as_ref().map(|m| m.as_str().to_owned())) + .or_else(|| config.current_model_id.clone()) + .filter(|s| !s.is_empty()); + (spec, mode, model) +} + +/// Build a claude/codex `SessionAgentTask` (the session-model port's `IAgentTask`) +/// from a resolved ACP build request, or `Ok(None)` for a non-session backend. +/// +/// This is the faithful port of clean-slate `build_runtime`'s per-conversation +/// assembly (`crates/aionui-app/src/session_runtime/mod.rs`): it resolves the +/// resume spec, the mode/model precedence, the MCP + preset + skills init surface, +/// the claude cc-switch provider env, and the codex sandbox/approval policy — so a +/// claude/codex session started through the ACP factory is byte-equivalent to one +/// started through the clean-slate registry. +pub async fn build_session_instance( + backend_label: &str, + inputs: SessionBuildInputs<'_>, + spawner: Arc, +) -> Result, AgentError> { + use aionui_session::{ + BackendConnection, ClaudeConnection, CodexConnection, McpServerSpec, SessionConfig, SessionInit, SessionSpec, + }; + + let connection: Box = match backend_label { + "claude" => Box::new(ClaudeConnection::new(spawner)), + "codex" => Box::new(CodexConnection::new(spawner)), + _ => return Ok(None), + }; + + let SessionBuildInputs { + conversation_id, + workspace, + config, + metadata, + session_snapshot, + backend_session_id, + mcp_server_repo, + broadcaster, + catalog_writeback, + acp_session_repo, + } = inputs; + + // GAP #1/#2 — the pure spec + mode/model mapping (resume anchor → Resume/Fresh, + // snapshot-wins precedence). Extracted so it is unit-testable in isolation, the + // exact sibling of clean-slate's `spec_and_config`. + let (spec, mode, model) = spec_mode_model(&conversation_id, backend_session_id, config, session_snapshot, metadata); + + // GAP #3 — MCP init surface: resolve user-configured servers to the neutral + // spec (clean-slate resolve_session_init), fold in the inline snapshot, then + // prepend the team coordination MCP. Same order as the app boundary. + let mut neutral = match mcp_server_repo { + Some(repo) => { + crate::mcp_resolve::resolve_session_mcp_servers( + repo.as_ref(), + config.mcp_server_ids.as_deref(), + &conversation_id, + broadcaster, + ) + .await + } + None => Vec::new(), + }; + neutral.extend(config.session_mcp_servers.iter().cloned()); + let mut mcp_servers: Vec = neutral.iter().map(session_server_to_spec).collect(); + if let Some(cfg) = config.team_mcp_stdio_config.as_ref() { + // Team-MCP is PREPENDED before the user's servers (clean-slate + legacy + // acp_assembler ordering). + let mut coordination = vec![team_mcp_server_spec(cfg)]; + coordination.append(&mut mcp_servers); + mcp_servers = coordination; + } + + // GAP #4 — preset_context + skills carried into the init surface. + let init = SessionInit { + mcp_servers, + skills: config.skills.clone(), + preset_context: config.preset_context.clone(), + // acp/codex resume via SessionSpec::Resume; no in-band snapshot needed. + session_snapshot: None, + resume: matches!(spec, SessionSpec::Resume { .. }), + }; + + let mut session_config = SessionConfig { + cwd: Some(workspace.clone()), + model, + mode, + init, + ..Default::default() + }; + + // GAP #5 — claude cc-switch provider env: inject ANTHROPIC_BASE_URL / + // ANTHROPIC_AUTH_TOKEN (third-party relay creds) into the spawn, mirroring the + // legacy ACP-claude path. Empty (no cc-switch config) = byte-identical spawn. + if backend_label == "claude" { + let provider_env = crate::cc_switch::read_claude_provider_env(); + if !provider_env.is_empty() { + session_config.spawn_env = provider_env + .into_iter() + .map(|(name, value)| aionui_common::EnvVar { name, value }) + .collect(); + let keys: Vec<&str> = session_config.spawn_env.iter().map(|e| e.name.as_str()).collect(); + tracing::info!(conv_id = %conversation_id, ?keys, "cc-switch: provider env injected into claude spawn"); + } + } + + // GAP #6 — codex sandbox + approval policy resolved from the requested mode + // (clean-slate codex_sandbox_for_mode / codex_approval_for_mode). A full-access + // / yolo mode escalates the sandbox and drops approval prompts; everything else + // (incl. None) leaves these None so the backend safe-defaults + // (workspace-write / on-request). + if backend_label == "codex" { + if let Some(sandbox) = codex_sandbox_for_mode(session_config.mode.as_deref()) { + tracing::info!(conv_id = %conversation_id, sandbox, "codex: sandbox policy resolved from requested mode"); + session_config.sandbox_mode = Some(sandbox.to_string()); + } + if let Some(approval) = codex_approval_for_mode(session_config.mode.as_deref()) { + tracing::info!(conv_id = %conversation_id, approval, "codex: approval policy resolved from requested mode"); + session_config.approval_policy = Some(approval.to_string()); + } + } + + // #4 — the persisted reasoning-effort level (claude only). There is no spawn-time + // effort flag (effort rides a post-open control_request, NOT `--`args like + // model/mode), so it cannot go into `SessionConfig`; instead we re-apply it AFTER + // open. codex effort is not a standalone selection (it rides collaborationMode via + // SetMode), so this is claude-scoped. Read from the snapshot's config_selections + // (the map `set_config_option` persisted under EFFORT_CONFIG_KEY). + let persisted_effort = (backend_label == "claude") + .then(|| { + session_snapshot.and_then(|s| { + s.config_selections + .iter() + .find(|(k, _)| k.as_str() == EFFORT_CONFIG_KEY) + .map(|(_, v)| v.as_str().to_owned()) + }) + }) + .flatten() + .filter(|s| !s.is_empty()); + + let backend = connection + .open_session(spec, session_config) + .await + .map_err(|e| AgentError::bad_gateway(format!("open {backend_label} session: {e}")))?; + + // Re-apply the persisted effort now that the session is open. The backend validates + // it against the current model's advertised catalog (permissive until the catalog + // is discovered) and drops it if unsupported — the same clear_invalid_desired_* + // semantics as the codex model/mode reconcile. Best-effort: a dispatch failure must + // not fail the open (the session is usable; only the persisted effort is lost). + if let Some(effort) = persisted_effort { + if let Err(e) = backend + .dispatch(Command::SetConfigOption { + option_id: EFFORT_CONFIG_KEY.to_owned(), + value: effort.clone(), + }) + .await + { + tracing::warn!(conv_id = %conversation_id, effort = %effort, error = %e, "session-port: re-applying persisted effort failed (session usable, effort not restored)"); + } else { + tracing::info!(conv_id = %conversation_id, effort = %effort, "session-port: re-applied persisted reasoning effort after open"); + } + } + + // GAP #7 (G5): project the backend's discovered catalog back into agent_metadata + // so the cold-start picker stays fresh. Best-effort, detached, off the open path. + if let Some((agent_id, catalog_tx)) = catalog_writeback { + spawn_catalog_writeback(agent_id, backend.clone(), catalog_tx); + } + + let task = SessionAgentTask::new_with_preload( + AgentType::Acp, + conversation_id, + workspace, + backend, + acp_session_repo, + &metadata.handshake, + ); + Ok(Some(crate::agent_task::AgentInstance::Session(task))) +} + +/// Convert a neutral `SessionMcpServer` (already stdio-launch-resolved by +/// `mcp_resolve`) into the crate-local `McpServerSpec`. Verbatim port of +/// clean-slate `session_runtime::session_server_to_spec`. +fn session_server_to_spec(server: &aionui_api_types::SessionMcpServer) -> aionui_session::McpServerSpec { + use aionui_api_types::SessionMcpTransport as T; + use aionui_session::{McpServerSpec, McpTransport}; + let sorted = |m: &std::collections::HashMap| -> Vec<(String, String)> { + let mut v: Vec<(String, String)> = m.iter().map(|(k, val)| (k.clone(), val.clone())).collect(); + v.sort_by(|a, b| a.0.cmp(&b.0)); + v + }; + let transport = match &server.transport { + T::Stdio { command, args, env } => McpTransport::Stdio { + command: command.clone(), + args: args.clone(), + env: sorted(env), + }, + T::Http { url, headers } | T::StreamableHttp { url, headers } => McpTransport::Http { + url: url.clone(), + headers: sorted(headers), + }, + T::Sse { url, headers } => McpTransport::Sse { + url: url.clone(), + headers: sorted(headers), + }, + }; + McpServerSpec { + name: server.name.clone(), + transport, + } +} + +/// The team coordination MCP server as a neutral stdio spec. Verbatim port of +/// clean-slate `session_runtime::team_mcp_server_spec` (name = TEAM_MCP_SERVER_NAME, +/// arg `mcp-team-stdio`, env PORT/TOKEN/SLOT_ID) so a session-model teammate joins +/// the SAME per-team TCP bridge the ACP path used. +fn team_mcp_server_spec(cfg: &aionui_api_types::TeamMcpStdioConfig) -> aionui_session::McpServerSpec { + use aionui_api_types::TeamMcpStdioConfig as C; + aionui_session::McpServerSpec { + name: aionui_api_types::TEAM_MCP_SERVER_NAME.to_owned(), + transport: aionui_session::McpTransport::Stdio { + command: cfg.binary_path.clone(), + args: vec!["mcp-team-stdio".to_owned()], + env: vec![ + (C::ENV_PORT.to_owned(), cfg.port.to_string()), + (C::ENV_TOKEN.to_owned(), cfg.token.clone()), + (C::ENV_SLOT_ID.to_owned(), cfg.slot_id.clone()), + ], + }, + } +} + +/// GAP #7 (G5): spawn the one-shot catalog write-back for a session-model +/// (claude/codex) backend. The ACP catalog (modes/models/commands) lands a beat +/// AFTER `open_session` returns (the session/new|load response is parsed +/// asynchronously by the reader), so this waits for a discovery (bounded to ~5s), +/// then forwards the projected partial via the registry's `CatalogSender` +/// (best-effort — re-discovery on the next open is the idempotent fallback). Off +/// the open hot path. Without this the `/api/agents` model/mode picker never +/// refreshes for claude/codex sessions (the exact "codex 无法选择模型" regression). +/// +/// Verbatim port of clean-slate `session_runtime::spawn_catalog_writeback`: wait +/// for MODELS specifically before committing (codex answers modes before models), +/// forwarding the best model-less partial only if the window elapses. +pub fn spawn_catalog_writeback( + agent_id: String, + backend: Arc, + catalog_tx: crate::registry::CatalogSender, +) { + tokio::spawn(async move { + let mut best_partial = None; + for _ in 0..100 { + let caps = backend.capabilities(); + if let Some(partial) = catalog_partial_from_caps(&caps) { + if !caps.available_models.is_empty() { + // Complete enough — models present → commit the full catalog. + catalog_tx.send_partial(agent_id, partial); + return; + } + // Modes/commands only so far — remember it, keep waiting for models. + best_partial = Some(partial); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + if let Some(partial) = best_partial { + catalog_tx.send_partial(agent_id, partial); + } + }); +} + +/// Project a backend's discovered `Capabilities` (modes / models / slash commands) +/// into an `AgentHandshake` partial for the `agent_metadata` catalog. Verbatim port +/// of clean-slate `session_runtime::catalog_partial_from_caps`: emits both the ACP +/// `config_options[]` wire shape AND the top-level `available_modes`/`available_models` +/// columns directly (the shape-stable path that keeps the codex model picker from +/// going empty). +fn catalog_partial_from_caps(caps: &aionui_session::Capabilities) -> Option { + let mut config_options = Vec::new(); + if !caps.available_modes.is_empty() { + config_options.push(serde_json::json!({ + "id": "mode", + "category": "mode", + "type": "select", + "currentValue": caps.current_mode, + "options": caps.available_modes.iter().map(|m| serde_json::json!({ + "value": m.id, "name": m.name, "description": m.description, + })).collect::>(), + })); + } + if !caps.available_models.is_empty() { + config_options.push(serde_json::json!({ + "id": "model", + "category": "model", + "type": "select", + "currentValue": caps.current_model, + "options": caps.available_models.iter().map(|m| serde_json::json!({ + "value": m.id, "name": m.name, "description": m.description, + })).collect::>(), + })); + } + let available_commands = if caps.slash_commands.is_empty() { + None + } else { + Some(serde_json::json!( + caps.slash_commands + .iter() + .map(|c| serde_json::json!({ + "name": c.name, "description": c.description, + })) + .collect::>() + )) + }; + if config_options.is_empty() && available_commands.is_none() { + return None; + } + let config_options = if config_options.is_empty() { + None + } else { + Some(serde_json::Value::Array(config_options)) + }; + // Also project the top-level `available_modes`/`available_models` fields directly + // (shape: `{available_models:[{id,label}]}`), which `apply_handshake` persists to + // the catalog columns VERBATIM — the authoritative, shape-stable path (matches what + // a live claude handshake stores), so the codex model picker never goes empty. + let available_modes = (!caps.available_modes.is_empty()).then(|| { + serde_json::json!({ + "available_modes": caps.available_modes.iter().map(|m| serde_json::json!({ + "id": m.id, "name": m.name, "description": m.description, + })).collect::>(), + "current_mode_id": caps.current_mode, + }) + }); + let available_models = (!caps.available_models.is_empty()).then(|| { + serde_json::json!({ + "available_models": caps.available_models.iter().map(|m| serde_json::json!({ + "id": m.id, "label": m.name, + })).collect::>(), + "current_model_id": caps.current_model, + }) + }); + Some(aionui_api_types::AgentHandshake { + config_options, + available_modes, + available_models, + available_commands, + ..Default::default() + }) +} + +/// Map a conversation's requested mode → the codex `thread/start.sandbox` string +/// (`SandboxMode`: `read-only` / `workspace-write` / `danger-full-access`, verified +/// `codex-cli/0.137.0/schema-full/ClientRequest.json` §SandboxMode), or `None` to keep +/// the backend's safe default (`unwrap_or("workspace-write")`). +/// +/// This runs at OPEN time and pre-seeds `thread/start.sandbox` — the sandbox axis the +/// tier reaches the FIRST turn through, since `thread/start` carries no `permissions` +/// field and `permissions` is mutually exclusive with `sandbox` (U1). The post-open +/// `reconcile_codex_mode` only applies the matching permission profile via SetMode, and +/// that `thread/settings/update{permissions}` write "applies to the NEXT turn" +/// (codex_conn `Command::SetMode`) — so WITHOUT seeding the restrictive sandbox here, a +/// read-only conversation's first turn would run under the permissive `workspace-write` +/// default and a write would succeed before the profile lands. We therefore seed BOTH +/// escalation (`full-access` → `danger-full-access`) AND restriction (`read-only` → +/// `read-only`) at the sandbox axis; the middle `workspace`/`auto` tier keeps the +/// `workspace-write` default (returned as `None`). +/// +/// The mode value reaching this boot helper is the persisted/config selection, which +/// under feature 012 "Plan B" is the LEGACY bare token (`full-access` / `read-only`); +/// the colon profile id (`:danger-full-access` / `:read-only`, e.g. from a readback that +/// skipped bare-mapping) and the legacy `yoloNoSandbox` alias stay recognized for +/// robustness. Kept in lockstep with `codex_conn::codex_perm::{normalize_to_profile_id, +/// profile_id_to_legacy_value}`. +fn codex_sandbox_for_mode(mode: Option<&str>) -> Option<&'static str> { + match mode.map(str::trim) { + // `agent-full-access` is the canonical codex full-access mode id since #608 + // (migration 021 rewrote builtin `yolo_id` `full-access`→`agent-full-access`, and + // `normalize_requested_mode` now resolves yolo aliases to it). The legacy + // `full-access` / `:danger-full-access` / `yoloNoSandbox` stay recognized for + // pre-021 persisted data — all four are the same danger-full-access tier. + Some(":danger-full-access" | "agent-full-access" | "full-access" | "yoloNoSandbox") => { + Some("danger-full-access") + } + Some(":read-only" | "read-only") => Some("read-only"), + _ => None, + } +} + +/// Map a conversation's requested mode → the codex `approvalPolicy` string, or +/// `None` to keep the default (`on-request`). Sibling of `codex_sandbox_for_mode`: +/// a full-access / yolo agent runs unattended → `"never"`. Recognizes the legacy bare +/// token `full-access` (the Plan B canonical value), the colon id `:danger-full-access`, +/// and the legacy `yoloNoSandbox` alias. Verbatim port of clean-slate +/// `session_runtime::codex_approval_for_mode`. +fn codex_approval_for_mode(mode: Option<&str>) -> Option<&'static str> { + match mode.map(str::trim) { + // Recognizes the #608 canonical `agent-full-access` alongside the legacy + // `full-access` / `:danger-full-access` / `yoloNoSandbox` (see codex_sandbox_for_mode). + Some(":danger-full-access" | "agent-full-access" | "full-access" | "yoloNoSandbox") => Some("never"), + _ => None, + } +} + +/// Discriminant name of a `SessionEvent`, for the pump's diagnostic debug log +/// (no payload — safe at debug; used to confirm which backend events actually +/// arrive when comparing the session path against the legacy ACP path). +fn session_event_name(e: &SessionEvent) -> &'static str { + match e { + SessionEvent::TurnStarted { .. } => "TurnStarted", + SessionEvent::MessageDelta { .. } => "MessageDelta", + SessionEvent::ThoughtDelta { .. } => "ThoughtDelta", + SessionEvent::ToolCall { .. } => "ToolCall", + SessionEvent::ToolResult { .. } => "ToolResult", + SessionEvent::TurnResult { .. } => "TurnResult", + SessionEvent::Detached { .. } => "Detached", + SessionEvent::Permission { .. } => "Permission", + SessionEvent::PermissionResolved { .. } => "PermissionResolved", + SessionEvent::UsageDelta { .. } => "UsageDelta", + SessionEvent::ConfigChanged { .. } => "ConfigChanged", + SessionEvent::BackendBound { .. } => "BackendBound", + SessionEvent::PromptAccepted { .. } => "PromptAccepted", + SessionEvent::Snapshot { .. } => "Snapshot", + other => { + // Fallback for the many additive variants the pump drops; a leaked + // debug string is fine (no payload). + let s: &'static str = match other { + SessionEvent::Plan { .. } => "Plan", + SessionEvent::Rewound { .. } => "Rewound", + SessionEvent::SubagentUpdate { .. } => "SubagentUpdate", + SessionEvent::SubagentDetail { .. } => "SubagentDetail", + SessionEvent::Notice { .. } => "Notice", + SessionEvent::ToolOutputDelta { .. } => "ToolOutputDelta", + SessionEvent::TurnDiffUpdated { .. } => "TurnDiffUpdated", + SessionEvent::Provisioning { .. } => "Provisioning", + _ => "Other", + }; + s + } + } +} + +/// Drain the backend's `events()` and re-broadcast each as an `AgentStreamEvent`. +fn spawn_event_pump( + mut events: BoxStream<'static, SessionEnvelope>, + runtime: Arc, + conversation_id: String, + session_repo: Option>, +) { + use futures_util::StreamExt as _; + // The pump owns ONLY the event stream (a broadcast `Receiver` handle — see + // `ClaudeSessionBackend::events`), NEVER an `Arc`. Holding a + // backend Arc here would be self-referential: the backend struct owns the + // `event_tx` this stream subscribes to, so a backend Arc in this task would keep + // `event_tx` alive, the stream would never see `Closed`, this loop would never + // exit, and the backend's `Drop` (the sole process reaper) would never run — + // leaking the child CLI. By capturing only the stream, the sole long-lived + // backend Arc is `SessionAgentTask.backend`; dropping the task (e.g. idle-kill + // removing it from the manager map) drops that Arc → backend `Drop` → reader + // abort + `kill_on_drop` → `event_tx` drops → this stream Closes → the loop ends. + tokio::spawn(async move { + // Per-tool accumulated live output for codex `ToolOutputDelta` (streamed + // command stdout). The frontend merges `tool_call` frames by call_id with a + // shallow REPLACE of `output` (hooks.ts: `{...existing, ...new}`), so we must + // send the CUMULATIVE text each time, not the delta — otherwise each chunk + // overwrites the last and only the final chunk shows. Keyed by item_id (== + // the ToolCall tool_use_id). The authoritative full output still arrives on + // the completed ToolResult, which harmlessly replaces this live view. + let mut tool_output: std::collections::HashMap = std::collections::HashMap::new(); + // In-flight workflow/subagent refs, mirroring `state::background_active` + // (any non-terminal roster entry ⇒ in-flight). claude's non-blocking + // Workflow turn emits MULTIPLE `result` frames: the LAUNCH result arrives + // while subagents are still running, and the TERMINAL result arrives only + // AFTER every `task_notification{completed}` (fixture 2.1.176 invariant: + // all completed precede all result). Forwarding the launch result's Finish + // would terminate the relay and drop the workflow's completion message, so + // we suppress the intermediate Finish until this set drains. + let mut workflow_inflight: std::collections::HashSet = std::collections::HashSet::new(); + // Remembered `tool_use_id` → tool name, learned from each `ToolCall` frame. + // A tool's lifecycle emits SEVERAL frames sharing one call_id — the initial + // ToolCall (name known), any codex `ToolOutputDelta` (name absent on the wire), + // and the terminal `ToolResult` (the wire `tool_result` block carries only + // tool_use_id, NOT the name). The frontend persists tool_call rows keyed by + // call_id (stream_persistence::persist_tool_call, upsert), so a later frame with + // an empty name would OVERWRITE the row's name to "" and the tool would render + // nameless. Stamp the remembered name onto every follow-up frame so the name + // survives — mirroring the reference `BackendOutputSink::emit_tool_result`, + // which re-sends the name on completion. Cleared per turn with `tool_output`. + let mut tool_name: std::collections::HashMap = std::collections::HashMap::new(); + // Tool calls of the CURRENT turn still awaiting their terminal `ToolResult` + // (call_id → name). If the turn ends without one — user cancel, process + // crash, or the CLI dropping the result — the persisted tool_call row would + // stay status "work" FOREVER and the frontend View-Steps spinner + // (`hasRunningToolMessages`) would never stop, surviving even reloads. The + // terminal arm below closes every remaining entry with a `Canceled` frame + // BEFORE the Finish (the relay stops forwarding a turn at Finish). + let mut open_tools: std::collections::HashMap = std::collections::HashMap::new(); + // Did the CURRENT turn emit any user-visible output (text / thinking / tool / + // plan / permission)? Mirrors the ACP path's `is_empty_turn` (agent_session_flow.rs): + // a clean terminal with this still `false` is a "blank reply" (ELECTRON-1JG) and + // gets a diagnostic Tip so the user isn't left staring at an empty bubble. Set as + // events are observed, reset at the per-turn terminal (with `tool_output`/`tool_name`). + let mut saw_visible_output = false; + // Did the CURRENT turn already reach a terminal `TurnResult`? Mirrors the + // reducer's `Running{terminal_result_seen}` flag. The pump consumes RAW + // pre-reducer events, so to classify a `Detached` the way the reducer's + // `crash_outcome` does — a crash mid-turn (no result yet) → Crashed, a + // Detached AFTER the turn's result → absorbed (I10) — it must track this + // itself. Without it, a Detached that trails a completed turn (idle-kill, + // clean shutdown) would be misread as a mid-turn crash. Set on the terminal + // TurnResult, reset on the next TurnStarted. + let mut terminal_result_seen = false; + while let Some(env) = events.next().await { + runtime.touch(); + tracing::debug!(conv_id = %conversation_id, event = session_event_name(&env.event), "session-pump: backend event"); + + // Empty-turn diagnostic Tip to emit for THIS terminal, if the turn was a + // clean blank reply. Computed in the terminal match arm below (while + // `saw_visible_output` still reflects this turn) and drained just before the + // Finish in the translate loop — a Tips after Finish would be dropped, since + // the relay breaks the turn on Finish. Per-iteration, so it never leaks + // across turns. + let mut pending_empty_turn_tip: Option = None; + + // ToolOutputDelta needs pump-local accumulation (see above), so it is + // handled here rather than in the stateless translate_event. + if let SessionEvent::ToolOutputDelta { item_id, text } = &env.event { + // Streamed tool stdout is user-visible output — this turn is not blank. + saw_visible_output = true; + let acc = tool_output.entry(item_id.clone()).or_default(); + acc.push_str(text); + let _ = runtime.tx.send(AgentStreamEvent::ToolCall(ToolCallEventData { + call_id: item_id.clone(), + // The wire delta carries no name; use the remembered one so this + // live-output frame doesn't overwrite the persisted row's name to "". + name: tool_name.get(item_id).cloned().unwrap_or_default(), + args: serde_json::Value::Null, + status: ToolCallStatus::Running, + input: None, + output: Some(acc.clone()), + description: None, + })); + continue; + } + + // Async catalog discovery (claude `initialize` / codex `model/list` + + // `collaborationMode/list` RESPONSE). Project it into an `AcpConfigOption` + // frame — the direct-CLI analogue of the ACP path's `emit_snapshot_events` + // catalog push. The frontend's `useAcpConfigOptions` handler REPLACES its + // whole snapshot on this frame and re-derives the picker's `canSwitch`, so a + // catalog that arrived ~6s after `open_session` (long after the frontend read + // an empty `config_options`) finally lights the model/mode selector. Built + // here, not in the stateless `translate_event`, because the current-value + // highlight needs the runtime's optimistic overrides. Emitted whole (model + + // mode categories together) so it never wipes a sibling category. + if let SessionEvent::CatalogUpdated { + models, + modes, + slash_commands, + } = &env.event + { + let mut config_options: Vec = Vec::new(); + if !modes.is_empty() { + config_options.push(aionui_api_types::AcpConfigOptionDto { + id: "mode".into(), + name: Some("Mode".into()), + label: None, + description: None, + category: Some("mode".into()), + option_type: "select".into(), + current_value: runtime.mode_override(), + options: modes + .iter() + .map(|m| aionui_api_types::AcpConfigSelectOptionDto { + value: m.id.clone(), + name: Some(m.name.clone()), + label: None, + description: m.description.clone(), + }) + .collect(), + }); + } + if !models.is_empty() { + config_options.push(aionui_api_types::AcpConfigOptionDto { + id: "model".into(), + name: Some("Model".into()), + label: None, + description: None, + category: Some("model".into()), + option_type: "select".into(), + current_value: runtime.model_override(), + options: models + .iter() + .map(|m| aionui_api_types::AcpConfigSelectOptionDto { + value: m.id.clone(), + name: Some(m.name.clone()), + label: None, + description: m.description.clone(), + }) + .collect(), + }); + } + // Reasoning-effort axis (claude per-model `supportedEffortLevels`). The + // frontend REPLACES its whole config-options snapshot on this frame, so we + // MUST re-emit effort here too — otherwise a late catalog push would wipe + // the effort option that `get_config_options` (REST) surfaced. The pump has + // no backend Arc, so the current model is resolved from the pushed catalog + // and the highlight comes from the runtime's optimistic effort override + // (claude emits no effort echo). Emitted only when the current model + // advertises efforts (union fallback when the current model is unknown). + let efforts = resolve_current_model_efforts(models, runtime.model_override().as_deref()); + if !efforts.is_empty() { + config_options.push(aionui_api_types::AcpConfigOptionDto { + id: "reasoning_effort".into(), + name: Some("Thinking".into()), + label: None, + description: None, + category: Some("thought_level".into()), + option_type: "select".into(), + current_value: runtime.effort_override(), + options: efforts + .iter() + .map(|e| aionui_api_types::AcpConfigSelectOptionDto { + value: e.clone(), + name: Some(e.clone()), + label: None, + description: None, + }) + .collect(), + }); + } + // No categories (both lists empty) → nothing to re-project; a spurious + // empty-snapshot frame would only clobber the frontend's picker. + if !config_options.is_empty() + && let Ok(v) = serde_json::to_value(serde_json::json!({ "config_options": config_options })) + { + let _ = runtime.tx.send(AgentStreamEvent::AcpConfigOption(v)); + } + // Slash-command catalog. claude advertises its command list in the + // async `initialize` response — the same late-catalog timing that + // strands the model/mode picker — and the frontend's mount-time REST + // read (`fetchAcpSlashCommands`) returns empty before it lands. The + // legacy ACP path recovers via a live `AvailableCommands` push + // (translate.rs `AvailableCommandsUpdate` arm); this is its direct-CLI + // analogue, so the `/` menu fills once discovery completes instead of + // staying empty until a manual refetch. + if !slash_commands.is_empty() { + let commands = slash_commands + .iter() + .map(|c| { + agent_client_protocol::schema::AvailableCommand::new( + c.name.clone(), + c.description.clone().unwrap_or_default(), + ) + }) + .collect(); + let _ = runtime + .tx + .send(AgentStreamEvent::AvailableCommands(AvailableCommandsEventData { + commands, + })); + } + continue; + } + + // Track in-flight workflow/subagent refs so a non-blocking Workflow's + // intermediate `result` frame does not prematurely terminate the turn. + // Mirrors `state::background_active`: a ref is in-flight while its status + // is non-terminal ({PendingInit, Running}); a terminal status + // ({Interrupted, Completed, Errored, Shutdown}) removes it. + if let SessionEvent::SubagentUpdate { r#ref, status, .. } = &env.event { + use aionui_session::SubagentStatus; + match status { + SubagentStatus::PendingInit | SubagentStatus::Running => { + workflow_inflight.insert(r#ref.clone()); + } + SubagentStatus::Interrupted + | SubagentStatus::Completed + | SubagentStatus::Errored + | SubagentStatus::Shutdown => { + workflow_inflight.remove(r#ref); + } + } + } + + // Is THIS TurnResult an intermediate (workflow-launch) result whose Finish + // must be suppressed? True only for a clean (non-error, non-cancel) result + // that arrives while a workflow is still in flight. An error/cancel result + // is always honoured as the terminal (the user must see it, and the + // fixture invariant only covers clean completion ordering). + let suppress_intermediate_finish = matches!(&env.event, SessionEvent::TurnResult { is_error, outcome, .. } + if !workflow_inflight.is_empty() + && !*is_error + && !matches!(outcome, aionui_session::TurnOutcome::Cancelled { .. })); + if suppress_intermediate_finish { + tracing::info!( + conv_id = %conversation_id, + inflight = workflow_inflight.len(), + "session-pump: suppressing intermediate workflow-launch Finish (turn stays open until workflow completes)" + ); + } + + // Drive the coarse status off the turn-boundary events so `status()` + // reflects running/finished (the app gates the sidebar spinner on it). + match &env.event { + SessionEvent::TurnStarted { .. } => { + runtime.set_status(ConversationStatus::Running); + // New turn: the prior turn's terminal no longer applies. + terminal_result_seen = false; + } + // Track the call's open/closed lifecycle. Also remember the name for + // `stamp_tool_name` — the map was previously never populated, so + // ToolOutputDelta/ToolResult follow-up frames went out nameless. + SessionEvent::ToolCall { tool_use_id, name, .. } => { + tool_name.insert(tool_use_id.clone(), name.clone()); + open_tools.insert(tool_use_id.clone(), name.clone()); + } + SessionEvent::ToolResult { tool_use_id, .. } => { + open_tools.remove(tool_use_id); + } + SessionEvent::TurnResult { .. } | SessionEvent::Detached { .. } if !suppress_intermediate_finish => { + runtime.set_status(ConversationStatus::Finished); + // Close every tool call the turn left open (cancel/crash/dropped + // result): emit a terminal `Canceled` frame per call so the + // persisted row leaves "work" and the frontend spinner stops. + // Must precede the Finish emitted by the translate loop below. + for (call_id, name) in open_tools.drain() { + tracing::info!( + conv_id = %conversation_id, + %call_id, + tool = %name, + "session-pump: closing tool call left open at turn end as canceled" + ); + let _ = runtime.tx.send(AgentStreamEvent::ToolCall(ToolCallEventData { + call_id, + name, + args: serde_json::Value::Null, + status: ToolCallStatus::Canceled, + input: None, + output: None, + description: None, + })); + } + // A terminal TurnResult decided this turn; a later Detached is then + // an absorbed teardown, not a mid-turn crash (see `crash_outcome`). + if matches!(env.event, SessionEvent::TurnResult { .. }) { + terminal_result_seen = true; + } + // Empty-turn (blank-reply) diagnostic, mirroring the ACP path + // (agent_session_flow.rs `prompt_outcome_from_stop_reason`): a turn + // that reached a CLEAN terminal (`TurnResult{is_error:false}`, not + // cancelled) without emitting any user-visible output gets an + // informational/warning Tip so the user isn't left with an empty + // bubble. `Detached` (process crash) is excluded — that surfaces as a + // crash error elsewhere, not a "the model had nothing to say" tip, and + // ACP likewise only tips on a completed prompt. An error result is + // excluded because it already terminates as `AgentStreamEvent::Error`. + if let SessionEvent::TurnResult { + is_error: false, + outcome, + .. + } = &env.event + && !saw_visible_output + { + pending_empty_turn_tip = empty_turn_tip(outcome); + } + // Live tool-output accumulators are per-turn; the authoritative + // full output already rode each ToolResult. Drop them so a long + // session doesn't retain every turn's stdout. + tool_output.clear(); + tool_name.clear(); + // Reset the per-turn visibility flag for the next turn. + saw_visible_output = false; + } + // Learn the CLI-assigned session id so send_message (Start) and the + // Finish stamping below carry it, matching the ACP path. + SessionEvent::BackendBound { + backend_session_id: Some(bid), + } => runtime.set_session_id(bid.clone()), + _ => {} + } + // Persist the Tier-2 side-effects the legacy ACP path wrote via + // AcpSessionSyncService (which this direct-CLI path bypasses). Best-effort: + // a repo error is warn-logged, never fatal to the stream. + if let Some(repo) = session_repo.as_ref() { + persist_side_effects(repo.as_ref(), &conversation_id, &env.event).await; + } + for mut ev in translate_event(env.event, &conversation_id, terminal_result_seen) { + // Keep the tool name alive across a call's multi-frame lifecycle (see + // `stamp_tool_name`): the terminal ToolResult frame leaves the name + // empty, and the upsert-by-call_id persistence would otherwise clobber + // the row's name to "". Runs before any routing decision below; + // no-op on non-ToolCall frames (e.g. the suppressed Finish). + stamp_tool_name(&mut tool_name, &mut ev); + // Record whether this turn produced user-visible output, so a clean + // terminal with none is detected as a blank reply (see the terminal + // match arm above). Checked against the translated frame so the + // definition matches the relay's own notion of visible output. + if event_is_user_visible_output(&ev) { + saw_visible_output = true; + } + // Emit the empty-turn diagnostic Tip immediately BEFORE the Finish it + // was computed for. It MUST precede Finish: the relay breaks the turn on + // Finish (stream_relay.rs), so a Tips sent afterwards would never be + // forwarded. `pending_empty_turn_tip` is only ever set on a clean + // TurnResult, whose translation is exactly one Finish, so this fires once. + if matches!(ev, AgentStreamEvent::Finish(_)) + && let Some(tip) = pending_empty_turn_tip.take() + { + let _ = runtime.tx.send(AgentStreamEvent::Tips(tip)); + } + // Suppress the intermediate workflow-launch Finish: the assistant's + // reply text already reached the frontend via MessageDelta→Text, so + // dropping this Finish loses no output — it only keeps the relay open + // so the workflow's later completion result can still be delivered. + // + // Emit a SegmentBreak in its place: the launch reply and the later + // completion reply are two independent claude outputs, so the relay + // must close the current text segment here. Otherwise both batches + // accumulate under one msg_id and the frontend renders them as a + // single bubble with no separator. SegmentBreak is consumed inside + // the relay (never forwarded to the WS), so it changes only bubble + // boundaries, not the wire contract. + if suppress_intermediate_finish && matches!(ev, AgentStreamEvent::Finish(_)) { + let _ = runtime.tx.send(AgentStreamEvent::SegmentBreak); + continue; + } + // Stamp the CLI session id onto the Finish frame, matching the ACP path + // which sends Finish{session_id}. The resume anchor rides it to the + // frontend. (Start is emitted by send_message, already stamped.) + // + // KNOWN DIVERGENCE (accepted, additive gap): claude emits its per-turn + // `UsageDelta` a few ms AFTER `TurnResult`, and origin's relay stops + // forwarding a turn once it sees this Finish — so the trailing + // AcpContextUsage frame does not reach the frontend and the context + // indicator stays blank. The ACP path avoids this only because its SDK + // blocks prompt() until usage is collected. Matching that needs an + // end-of-turn "collect usage" barrier (or wiring get_context_usage) and + // is deferred; the core turn flow is otherwise frame-equivalent. + if let AgentStreamEvent::Finish(data) = &mut ev + && data.session_id.is_none() + { + data.session_id = runtime.session_id(); + } + // A send error only means no live subscribers — harmless. + let _ = runtime.tx.send(ev); + } + } + }); +} + +/// Pure decision (FCIS core): does this terminal `TurnResult` prove the stored +/// resume anchor is dead, so the next turn must open Fresh? +/// +/// A resume against a backend session the CLI no longer knows fails with a +/// structural error ("No conversation found" / `error_during_execution`), NOT an +/// ordinary tool/turn error (those terminate `is_error:false` or with other text). +/// Classified through the SAME single-source predicate the clean-slate +/// `Orchestrator` uses (`aionui_session::is_unrecoverable_resume_error`), so a +/// backend wording change is fixed in one place. A user-cancelled turn is excluded: +/// claude reports an interrupt as `is_error` with cancel-noise text, but the anchor +/// is still good. +fn is_dead_resume_anchor(event: &SessionEvent) -> bool { + use aionui_session::{TurnOutcome, is_unrecoverable_resume_error}; + let SessionEvent::TurnResult { + is_error, + result_text, + outcome, + .. + } = event + else { + return false; + }; + if !is_error || matches!(outcome, TurnOutcome::Cancelled { .. }) { + return false; + } + let reason = aionui_session::ErrorReason::Backend { + api_error_status: None, + message: result_text.clone(), + }; + is_unrecoverable_resume_error(&reason) +} + +/// Persist the backend-observed session identity + config to `acp_session`, the +/// SAME writes the legacy `AcpSessionSyncService` domain-event consumer performed +/// for the ACP-manager path. Without this the resume anchor +/// (`build_session_instance` GAP #1) and the mode/model precedence source (GAP #2) +/// are never written, so a restart always loses continuity. +async fn persist_side_effects(repo: &dyn IAcpSessionRepository, conversation_id: &str, event: &SessionEvent) { + // Self-heal a dead resume anchor: a turn that failed *because* the stored + // backend session id no longer resolves must null that id, or every subsequent + // send re-resumes the same dead session and the conversation wedges forever. + // Nulling (not deleting) keeps config/runtime state; the next open reads a + // `None` anchor → Fresh → rebinds a live id. This restores the self-heal the + // direct-CLI path dropped: clean-slate `Orchestrator` emits `BackendBound{None}` + // and legacy ACP does `rebuild_after_session_not_found` → `clear_session_id`. + if is_dead_resume_anchor(event) { + match repo.clear_session_id(conversation_id).await { + Ok(_) => tracing::info!( + conversation_id, + "session-sync: cleared dead resume anchor (unrecoverable resume error) — next turn opens Fresh" + ), + Err(err) => { + tracing::warn!(conversation_id, error = %err, "session-sync: clear_session_id failed") + } + } + } + match event { + // The CLI-echoed backend session id — written immediately (no debounce) so + // the next turn takes the resume path even if the process crashes. `None` + // (lost-backend self-heal) leaves the stored anchor as-is; a fresh rebind + // happens on the next open. + SessionEvent::BackendBound { + backend_session_id: Some(bid), + } => { + if let Err(err) = repo.update_session_id(conversation_id, bid).await { + tracing::warn!(conversation_id, error = %err, "session-sync: update_session_id failed"); + } + } + // A confirmed mode/model switch → persist so the next respawn/resume seeds + // the user's selection (mirrors ObservedModeSynced / ObservedModelSynced). + SessionEvent::ConfigChanged { mode, model } if mode.is_some() || model.is_some() => { + let params = SaveRuntimeStateParams { + current_mode_id: mode.as_ref().map(|m| Some(m.as_str())), + current_model_id: model.as_ref().map(|m| Some(m.as_str())), + config_selections_json: None, + context_usage_json: None, + }; + if let Err(err) = repo.save_runtime_state(conversation_id, ¶ms).await { + tracing::warn!(conversation_id, error = %err, "session-sync: save_runtime_state failed"); + } + } + _ => {} + } +} + +/// Extract the picked option id from the confirm `data` payload. The frontend sends +/// either a bare string (the option_id) or an object `{option_id|optionId|value}`. +/// Mirrors the ACP path's `confirm_option_id`. +fn confirm_option_id(data: &serde_json::Value) -> Option { + match data { + serde_json::Value::String(v) => Some(v.clone()), + serde_json::Value::Object(map) => map + .get("option_id") + .or_else(|| map.get("optionId")) + .or_else(|| map.get("value")) + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned), + _ => None, + } +} + +/// Generic allow / allow-always / reject options for an ordinary tool-approval +/// permission card. `confirm()` maps these option ids back to a `PermissionDecision`. +fn default_permission_options() -> Vec { + use crate::protocol::events::{AcpPermissionOptionData, AcpPermissionOptionKind}; + vec![ + AcpPermissionOptionData { + option_id: PERM_ALLOW.to_owned(), + name: "Allow".to_owned(), + kind: AcpPermissionOptionKind::AllowOnce, + meta: None, + }, + AcpPermissionOptionData { + option_id: PERM_ALLOW_ALWAYS.to_owned(), + name: "Allow Always".to_owned(), + kind: AcpPermissionOptionKind::AllowAlways, + meta: None, + }, + AcpPermissionOptionData { + option_id: PERM_REJECT.to_owned(), + name: "Reject".to_owned(), + kind: AcpPermissionOptionKind::RejectOnce, + meta: None, + }, + ] +} + +/// Project an AskUserQuestion tool `input` into permission-card options the user can +/// pick. `input` shape (claude, live-captured): `{questions:[{question, header, +/// options:[{label, description}], multiSelect}]}`. The frontend card is single-select +/// (one radio group, one confirm), so we surface the FIRST question's option labels as +/// the choices — `option_id == label` so `confirm()` can pass the picked label straight +/// into `AnswerPermission.selected` (claude keys the answer by label). A multi-question +/// AskUserQuestion degrades to answering the first question (a known single-select +/// frontend limitation — the remaining questions claude silently drops, same as the +/// legacy single-question path). Returns empty when the shape is absent/unparseable, so +/// the caller falls back to allow/deny. +fn ask_user_question_options( + input: Option<&serde_json::Value>, +) -> Vec { + use crate::protocol::events::{AcpPermissionOptionData, AcpPermissionOptionKind}; + let Some(first_q) = input + .and_then(|i| i.get("questions")) + .and_then(|q| q.as_array()) + .and_then(|arr| arr.first()) + else { + return Vec::new(); + }; + let Some(opts) = first_q.get("options").and_then(|o| o.as_array()) else { + return Vec::new(); + }; + opts.iter() + .filter_map(|o| o.get("label").and_then(|l| l.as_str())) + .map(|label| AcpPermissionOptionData { + // option_id == label: confirm() forwards it as the chosen answer label. + option_id: label.to_owned(), + name: label.to_owned(), + kind: AcpPermissionOptionKind::AllowOnce, + meta: None, + }) + .collect() +} + +/// Keep a tool's name alive across the multiple `AgentStreamEvent::ToolCall` frames +/// that share one `call_id` over its lifecycle. +/// +/// A single tool call surfaces as several frames keyed by the same `call_id`: the +/// initial `ToolCall` (status Running, name known); any codex `ToolOutputDelta` +/// (streamed stdout, name absent on the wire); and the terminal `ToolResult` (the +/// wire `tool_result` block carries only `tool_use_id`, never the name — so +/// `translate_event` leaves it empty). The frontend persists tool_call rows by +/// upsert on `call_id` (`stream_persistence::persist_tool_call`), so a later +/// empty-name frame would OVERWRITE the row's name to `""` and the tool would render +/// nameless. +/// +/// This learns the name from the first frame that carries one and stamps it back +/// onto any later empty-name frame for the same `call_id`, mirroring the reference +/// `BackendOutputSink::emit_tool_result`, which re-sends the name on completion. +/// `names` is the pump-local map (cleared per turn); non-`ToolCall` events are inert. +fn stamp_tool_name(names: &mut std::collections::HashMap, ev: &mut AgentStreamEvent) { + let AgentStreamEvent::ToolCall(data) = ev else { + return; + }; + if data.name.is_empty() { + if let Some(known) = names.get(&data.call_id) { + data.name = known.clone(); + } + } else { + names.insert(data.call_id.clone(), data.name.clone()); + } +} + +/// Translate one clean-slate `SessionEvent` into zero or more origin +/// `AgentStreamEvent`s. The fold SHAPE mirrors the clean-slate TurnFinalizer, but +/// the output targets origin's `AgentStreamEvent` enum instead of `ConvDomainEvent`. +/// Whether a translated stream event represents user-visible turn output — +/// anything that renders in chat. Mirrors the ACP path's +/// `event_is_user_visible_output` (agent_session_flow.rs) so the direct-CLI +/// empty-turn detection uses the same definition of "the turn said something". +fn event_is_user_visible_output(event: &AgentStreamEvent) -> bool { + matches!( + event, + AgentStreamEvent::Text(_) + | AgentStreamEvent::Thinking(_) + | AgentStreamEvent::ToolCall(_) + | AgentStreamEvent::AcpToolCall(_) + | AgentStreamEvent::ToolGroup(_) + | AgentStreamEvent::Plan(_) + | AgentStreamEvent::Permission(_) + | AgentStreamEvent::AcpPermission(_) + ) +} + +/// Build the empty-turn diagnostic Tip for a clean terminal that produced no +/// user-visible output, mirroring the ACP path (agent_session_flow.rs:388-448): +/// a normal `EndTurn` is an informational "no reply" note; any other stop reason +/// (truncation / refusal / failure) is a warning naming the cause. Codes match +/// the `conversation.agentTip.codes.*` i18n keys the frontend `MessageTips` +/// renderer localizes. Cancelled is `None` (never a blank-reply; the caller also +/// guards it) so a user interrupt never surfaces a spurious tip. +fn empty_turn_tip(outcome: &aionui_session::TurnOutcome) -> Option { + use aionui_session::{StopReason, TruncationKind, TurnOutcome}; + let (tip_type, code) = match outcome { + TurnOutcome::EndTurn + | TurnOutcome::Completed { + stop_reason: StopReason::EndTurn, + } => (TipType::Info, "ACP_EMPTY_TURN"), + TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::MaxTokens), + } => (TipType::Warning, "ACP_EMPTY_TURN_MAX_TOKENS"), + TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::MaxTurns), + } => (TipType::Warning, "ACP_EMPTY_TURN_MAX_TURN_REQUESTS"), + TurnOutcome::Completed { + stop_reason: StopReason::Refused { .. }, + } => (TipType::Warning, "ACP_EMPTY_TURN_REFUSAL"), + // Other truncation kinds (context window / budget / bare wire-end) and a + // clean `Failed` have no dedicated ACP code — surface the generic warning + // so the user still sees "the turn ended without a reply" with a hint. + TurnOutcome::Completed { .. } | TurnOutcome::Failed => (TipType::Warning, "ACP_EMPTY_TURN"), + TurnOutcome::Cancelled { .. } => return None, + }; + Some(TipsEventData { + content: String::new(), + tip_type, + code: Some(code.to_owned()), + params: None, + }) +} + +/// `terminal_result_seen`: did the current turn already reach a terminal +/// `TurnResult`? Only consulted for `Detached` — it lets this stateless fn +/// replicate the reducer's `crash_outcome` guard (a Detached AFTER the turn's +/// result is an absorbed teardown, not a crash). Immaterial for every other arm. +fn translate_event(event: SessionEvent, conversation_id: &str, terminal_result_seen: bool) -> Vec { + match event { + // NOTE: the Start lifecycle frame is emitted by `send_message` (before + // dispatch), mirroring the ACP path which emits Start right before prompt(). + // The backend's own turn-start signals — claude/codex `PromptAccepted` + // (arrives AFTER the first text delta) and the orchestrator-lowered + // `TurnStarted` (never reaches this stream) — are therefore NOT re-projected + // to Start here, or the frontend would see a late/duplicate turn boundary. + SessionEvent::PromptAccepted { .. } | SessionEvent::TurnStarted { .. } => Vec::new(), + SessionEvent::MessageDelta { text, .. } => { + vec![AgentStreamEvent::Text(TextEventData { content: text })] + } + SessionEvent::ThoughtDelta { text, .. } => { + vec![AgentStreamEvent::Thinking(ThinkingEventData { + content: text, + subject: None, + duration: None, + status: Some("thinking".into()), + })] + } + SessionEvent::ToolCall { + tool_use_id, + name, + input, + .. + } => { + vec![AgentStreamEvent::ToolCall(ToolCallEventData { + call_id: tool_use_id, + name, + args: input.clone(), + status: ToolCallStatus::Running, + input: Some(input), + output: None, + description: None, + })] + } + SessionEvent::ToolResult { + tool_use_id, + is_error, + content, + .. + } => { + let output = tool_result_text(&content); + vec![AgentStreamEvent::ToolCall(ToolCallEventData { + call_id: tool_use_id, + name: String::new(), + args: serde_json::Value::Null, + status: if is_error { + ToolCallStatus::Error + } else { + ToolCallStatus::Completed + }, + input: None, + output, + description: None, + })] + } + SessionEvent::TurnResult { + is_error, + result_text, + outcome, + .. + } => { + // A user-cancelled turn is NOT an error: claude reports its interrupt as an + // is_error result (e.g. `error_during_execution` / an aborted-tool + // diagnostic), but the user asked for it — so a cancel ends with a plain + // Finish, no error (the origin frontend lacks the clean-slate cancel-noise + // suppression, so we suppress at the source). + let is_cancel = matches!(outcome, aionui_session::TurnOutcome::Cancelled { .. }); + if is_error && !is_cancel && !result_text.trim().is_empty() { + // A genuine turn error terminates as AgentStreamEvent::Error carrying the + // FULL origin error model (code / ownership / retryable / + // feedback_recommended), NOT a plain Tips. The relay reads + // Error{code,retryable} to drive auto-replay + error classification + // (stream_relay::terminal_from_event) and the frontend renders ownership/ + // feedback from it; a Tips carries none of that and is not even seen as a + // terminal. Classify the result text through the SAME path the ACP empty- + // turn error uses (AgentError::bad_gateway → classify_upstream_detail), so + // provider/billing/rate-limit/lifecycle errors are categorized identically. + // Error IS the terminal (relay breaks on it), so we do NOT also emit Finish. + let stream_error = + AgentSendError::from_agent_error(AgentError::bad_gateway(result_text)).into_stream_error(); + return vec![AgentStreamEvent::Error(stream_error)]; + } + vec![AgentStreamEvent::Finish(FinishEventData::default())] + } + SessionEvent::Detached { exit, redacted_summary } => { + // A process exit is only an ERROR when it is a genuine mid-turn crash. + // Classify it EXACTLY as the clean-slate reducer's `crash_outcome` does + // (the pump consumes raw pre-reducer events, so it must replicate that + // pure fn rather than inherit its verdict): + // - terminal result already seen this turn → absorbed teardown (I10); + // - clean exit-0, no result → EmptyTurn-class (a blank turn, not a + // crash) — the empty-turn Tip already rode the status match above; + // - signal / non-zero / unknown(None) exit, no result → CRASH. + // Only the crash case surfaces as an error; the rest end with a plain + // Finish (behaviour-preserving). This restores the legacy ACP path's + // `AcpError::Disconnected → UserAgentDisconnected` terminal that the + // direct-CLI bridge previously dropped: a CLI that dies mid-reply used + // to render as a normal (empty) completion instead of a "disconnected, + // reconnect" error card. + match aionui_session::crash_outcome(terminal_result_seen, exit) { + aionui_session::Outcome::Crashed => { + // Route through the SAME classifier legacy used, so the frontend + // gets the identical code/ownership/retryable/resolution. The + // allowlisted `redacted_summary` (already stripped of secrets at + // the backend boundary) rides the error message as the user-facing + // reason — mirroring `CloseReason::ProcessExited: {summary}`; + // without it the card shows only a bare exit code. + let acp_err = crate::protocol::error::AcpError::Disconnected { + exit_code: exit.and_then(|e| e.code), + signal: exit.and_then(|e| e.signal).map(|s| s.to_string()), + stderr: redacted_summary.clone().unwrap_or_default(), + }; + let mut stream_error = + AgentSendError::from_agent_error(AgentError::Acp(acp_err)).into_stream_error(); + if let Some(summary) = redacted_summary.filter(|s| !s.trim().is_empty()) { + stream_error.message = format!("{}: {summary}", stream_error.message); + } + vec![AgentStreamEvent::Error(stream_error)] + } + aionui_session::Outcome::CleanNoResult | aionui_session::Outcome::FollowResult => { + vec![AgentStreamEvent::Finish(FinishEventData::default())] + } + } + } + // Interactive tool approval: surface as an AcpPermission Request so the + // frontend renders the allow/deny card. The `tool_call_id` MUST equal the + // `request_id` — `SessionAgentTask::confirm` dispatches `AnswerPermission` + // keyed on the same id (the frontend echoes the `call_id` it received here). + // `input` (AskUserQuestion question content) rides as `raw_input`; the + // generic `Approved`/`Denied` options let the reducer + card render. + SessionEvent::Permission { + request_id, + tool_name, + input, + .. + } => { + // The frontend permission card renders whatever `options[]` we send as the + // selectable choices (MessageAcpPermission maps each to a radio). So the + // options MUST reflect what the user is actually choosing between: + // - AskUserQuestion → the question's own options (labels), so the user + // answers the question. `confirm()` maps the picked label to the + // AnswerPermission `selected` (claude keys the answer by it). + // - any other tool approval → generic Allow / Allow Always / Reject. + // (Before, EVERY permission — including AskUserQuestion — was hard-coded to + // allow/deny, so a question rendered as an allow/deny card. TIO: the question + // content in `input` is user-facing, not a sensitive tool body.) + let is_ask = tool_name.as_deref() == Some("AskUserQuestion"); + let options = if is_ask { + ask_user_question_options(input.as_ref()) + } else { + Vec::new() + }; + let options = if options.is_empty() { + default_permission_options() + } else { + options + }; + vec![AgentStreamEvent::AcpPermission( + crate::protocol::events::AcpPermissionEventData::Request( + crate::protocol::events::AcpPermissionRequestData { + session_id: conversation_id.to_owned(), + tool_call: crate::protocol::events::AcpPermissionToolCall { + tool_call_id: request_id, + status: None, + title: tool_name, + kind: None, + raw_input: input, + raw_output: None, + content: None, + locations: None, + meta: None, + }, + options, + meta: None, + }, + ), + )] + } + // Per-turn usage/cost → the AcpContextUsage passthrough frame the frontend + // usage indicator reads (shape: cumulative token counters). + SessionEvent::UsageDelta { + input_tokens, + output_tokens, + total_tokens, + cost_usd, + } => { + // The frontend ContextUsageIndicator reads `used` (tokens consumed) and, + // optionally, `size` (context window) + `cost` — the exact shape the ACP + // path forwards (the claude-agent-acp SDK's UsageUpdate: {used, size, + // cost:{amount,currency}}). Emitting the raw {input_tokens,…} shape left + // the indicator blank (no `used` key). `size` is omitted: UsageDelta + // carries no context-window figure (that rides the separate + // get_context_usage control probe, not wired here), and the frontend + // guards `if size>0` so its absence is safe. `used` = total_tokens (the + // genuine cumulative total the adapter already computed, incl. cache). + let mut usage = serde_json::json!({ "used": total_tokens }); + if let Some(cost) = cost_usd { + usage["cost"] = serde_json::json!({ "amount": cost, "currency": "USD" }); + } + // Keep the raw counters too (harmless extra keys) for any richer consumer. + usage["input_tokens"] = serde_json::json!(input_tokens); + usage["output_tokens"] = serde_json::json!(output_tokens); + vec![AgentStreamEvent::AcpContextUsage(usage)] + } + // A confirmed mode/model switch is NOT forwarded as a stream frame. The origin + // frontend's mode/model pickers (AgentModeSelector / AcpModelSelector) track the + // selection in local state updated optimistically on the PUT /config-options + // call + its REST response — they do NOT consume a config stream frame. And the + // origin `useAcpMessage` has no `acp_config_option` case, so any such frame falls + // into its `default:` arm and lights the turn timer bar (`setRunning(true)`) — + // the "switching mode shows a spurious timer" regression. So emit nothing here; + // the selection persist is handled separately by `persist_side_effects`. + SessionEvent::ConfigChanged { .. } => Vec::new(), + // Handled earlier in the pump (needs runtime overrides for the current-value + // highlight; projected to an AcpConfigOption frame there). Never reaches this + // stateless translator, but the match is total so give it an explicit no-op arm. + SessionEvent::CatalogUpdated { .. } => Vec::new(), + // Live plan / to-do snapshot (codex `turn/plan/updated`; claude never emits it). + // origin has `AgentStreamEvent::Plan` + a `MessagePlan` renderer that reads + // `entries[].content` + `entries[].status` where status is snake_case + // (`pending`/`in_progress`/`completed`). Our `PlanStatus` serializes PascalCase, + // so map it to the frontend contract explicitly rather than serde-dumping the + // struct (a raw dump would send `Completed` and the card would never tick). + SessionEvent::Plan { entries, .. } => { + let entries: Vec = entries + .iter() + .map(|e| { + let status = match e.status { + aionui_session::PlanStatus::Pending => "pending", + aionui_session::PlanStatus::InProgress => "in_progress", + aionui_session::PlanStatus::Completed => "completed", + }; + serde_json::json!({ "content": e.content, "status": status }) + }) + .collect(); + vec![AgentStreamEvent::Plan( + crate::protocol::events::session_updates::PlanEventData { + session_id: None, + entries, + }, + )] + } + // Out-of-turn advisory (codex `warning`/`guardianWarning`/`configWarning`/ + // `deprecationNotice`; claude a rejected mode/model/effort set surfaced by + // `sniff_set_config_reject`). Both backends emit `Notice` *specifically so a + // failed/advisory event is VISIBLE instead of silently dropped* — re-dropping it + // here would re-introduce exactly the silent-degradation the backends were coded + // to avoid (e.g. a rejected effort switch would look like it succeeded). Surface + // it as a `Tips` frame — the one advisory frame the origin frontend already + // renders (`MessageTips`, warning/info styling). NOTE: origin's `useAcpMessage` + // has no explicit `tips` case, so a `tips` frame lands in its `default:` arm, + // which is benign for display (it renders via `mergeLiveMessage`) but also calls + // `setRunning(true)`. That is acceptable here: a Notice only arrives mid/around a + // turn that is already running (or immediately re-settled by the turn's terminal + // Finish), so it does not manufacture a spurious idle timer the way a config frame + // would. `NoticeLevel` has only Info/Warning (no Error tier), matching TipType. + SessionEvent::Notice { level, message } => { + let tip_type = match level { + aionui_session::NoticeLevel::Info => TipType::Info, + aionui_session::NoticeLevel::Warning => TipType::Warning, + }; + vec![AgentStreamEvent::Tips(TipsEventData { + content: message, + tip_type, + code: None, + params: None, + })] + } + // Events with no origin-side counterpart (or purely internal) are dropped. + // Cancel folds into the Finish emitted by the resulting terminal; Heartbeat, + // PromptAccepted, Snapshot, Lagged, item lifecycle, subagent/rewound/etc. are + // not part of origin's AgentStreamEvent vocabulary. codex ToolOutputDelta / + // TurnDiffUpdated / SubagentUpdate are also dropped for now — separate + // follow-ups (each needs its own origin frame + renderer verification). + _ => Vec::new(), + } +} + +/// Flatten a tool result's content parts into a single text string for the +/// `ToolCallEventData.output` field (origin renders that). +fn tool_result_text(content: &[ToolResultContent]) -> Option { + let mut buf = String::new(); + for part in content { + if let ToolResultContent::Text(t) = part { + if !buf.is_empty() { + buf.push('\n'); + } + buf.push_str(t); + } + } + if buf.is_empty() { None } else { Some(buf) } +} + +#[cfg(test)] +mod build_mapping_tests { + //! Ported from clean-slate `session_runtime::tests` (the `spec_and_config` + + //! `catalog_partial_from_caps` + `codex_sandbox`/`approval` + `session_server_to_spec` + //! suite), adapted to the port's decomposed `spec_mode_model` inputs (AcpBuildExtra + + //! PersistedSessionState) instead of a `ConversationRow`. Same assertions. + use super::*; + use crate::shared_kernel::{ModeId, ModelId}; + use aionui_session::SessionSpec; + + fn snapshot(mode: Option<&str>, model: Option<&str>) -> PersistedSessionState { + PersistedSessionState { + current_mode_id: mode.map(ModeId::new), + current_model_id: model.map(ModelId::new), + ..Default::default() + } + } + + // Minimal catalog row for spec_mode_model's mode-normalize step. `backend` + + // `yolo_id` drive the alias mapping; everything else is inert here. + fn test_metadata(backend: Option<&str>, yolo_id: Option<&str>) -> aionui_api_types::AgentMetadata { + use aionui_api_types::{AgentHandshake, AgentMetadata, AgentSource, AgentSourceInfo, BehaviorPolicy}; + use aionui_common::AgentType; + AgentMetadata { + id: "test".into(), + icon: None, + name: "Test".into(), + name_i18n: None, + description: None, + description_i18n: None, + backend: backend.map(ToOwned::to_owned), + agent_type: AgentType::Acp, + agent_source: AgentSource::Builtin, + agent_source_info: AgentSourceInfo::default(), + enabled: true, + available: true, + command: None, + resolved_command: None, + args: vec![], + env: vec![], + native_skills_dirs: None, + behavior_policy: BehaviorPolicy::default(), + yolo_id: yolo_id.map(ToOwned::to_owned), + sort_order: 0, + team_capable: false, + last_check_status: None, + last_check_kind: None, + last_check_error_code: None, + last_check_error_message: None, + last_check_error_details: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, + handshake: AgentHandshake::default(), + has_command_override: false, + env_override_key_count: 0, + } + } + + #[test] + fn spec_fresh_when_no_anchor() { + let cfg = AcpBuildExtra::default(); + let (spec, mode, model) = spec_mode_model("conv_1", None, &cfg, None, &test_metadata(Some("claude"), None)); + assert!(matches!(spec, SessionSpec::Fresh { session_id } if session_id == "conv_1")); + assert_eq!(mode, None); + assert_eq!(model, None); + } + + #[test] + fn spec_resume_when_anchor_present() { + let cfg = AcpBuildExtra { + session_mode: Some("plan".into()), + current_model_id: Some("claude-x".into()), + ..Default::default() + }; + let (spec, mode, model) = spec_mode_model( + "conv_1", + Some("bsid-xyz".into()), + &cfg, + None, + &test_metadata(Some("claude"), None), + ); + assert!(matches!( + spec, + SessionSpec::Resume { backend_session_id: Some(b), .. } if b == "bsid-xyz" + )); + assert_eq!(mode.as_deref(), Some("plan")); + assert_eq!(model.as_deref(), Some("claude-x")); + } + + // The interactive-switch-persisted snapshot selection MUST win over the + // create-time config values on resume — else the user's choice is dropped on + // respawn. (Clean-slate: spec_and_config_runtime_model_overrides_stale_model_column.) + #[test] + fn snapshot_mode_model_override_create_time_config() { + let cfg = AcpBuildExtra { + session_mode: Some("default".into()), + current_model_id: Some("claude-sonnet-4-6".into()), + ..Default::default() + }; + let snap = snapshot(Some("plan"), Some("claude-opus-4-8")); + let (_spec, mode, model) = spec_mode_model( + "conv_1", + Some("bsid".into()), + &cfg, + Some(&snap), + &test_metadata(Some("claude"), None), + ); + assert_eq!(mode.as_deref(), Some("plan"), "snapshot mode wins"); + assert_eq!(model.as_deref(), Some("claude-opus-4-8"), "snapshot model wins"); + } + + // Empty strings are filtered → None so the backend safe-defaults (never an empty + // model/mode token on the wire). + #[test] + fn empty_selections_filter_to_none() { + let cfg = AcpBuildExtra { + session_mode: Some(String::new()), + current_model_id: Some(String::new()), + ..Default::default() + }; + let (_spec, mode, model) = spec_mode_model("conv_1", None, &cfg, None, &test_metadata(Some("claude"), None)); + assert_eq!(mode, None); + assert_eq!(model, None); + } + + // HIGH-1 regression guard (equivalence audit): a persisted generic mode alias must + // be normalized to the backend-native id via the catalog row — the SAME transform + // the ACP path applies. Without it the raw alias reaches the backend on resume + // (claude rejects an unknown permission-mode; codex mis-policies). + #[test] + fn mode_alias_is_normalized_via_catalog() { + // codex: yoloNoSandbox → the row's yolo_id (full-access); default → auto. + let codex = test_metadata(Some("codex"), Some("full-access")); + let yolo_cfg = AcpBuildExtra { + session_mode: Some("yoloNoSandbox".into()), + ..Default::default() + }; + let (_s, mode, _m) = spec_mode_model("c", None, &yolo_cfg, None, &codex); + assert_eq!( + mode.as_deref(), + Some("full-access"), + "yoloNoSandbox → codex native yolo_id" + ); + + let def_cfg = AcpBuildExtra { + session_mode: Some("default".into()), + ..Default::default() + }; + let (_s, mode, _m) = spec_mode_model("c", None, &def_cfg, None, &codex); + assert_eq!(mode.as_deref(), Some("auto"), "codex default → auto"); + + // A native / non-alias mode passes through unchanged. + let plan_cfg = AcpBuildExtra { + session_mode: Some("plan".into()), + ..Default::default() + }; + let (_s, mode, _m) = spec_mode_model("c", None, &plan_cfg, None, &test_metadata(Some("claude"), None)); + assert_eq!(mode.as_deref(), Some("plan"), "non-alias mode unchanged"); + } + + /// G5: a discovered catalog projects mode/model as ACP `configOptions[]` + slash + /// commands as `available_commands`; an empty catalog projects `None` (never + /// clobbers the stored catalog). Ported verbatim from clean-slate. + #[test] + fn catalog_partial_projects_discovered_modes_models_commands() { + use aionui_session::{ModeInfo, ModelInfo, SlashCommandInfo}; + let caps = aionui_session::Capabilities { + available_modes: vec![ModeInfo { + id: "plan".into(), + name: "Plan".into(), + description: Some("Planning".into()), + }], + current_mode: Some("plan".into()), + available_models: vec![ModelInfo { + id: "opus".into(), + name: "Opus".into(), + description: None, + reasoning_efforts: Vec::new(), + }], + current_model: Some("opus".into()), + slash_commands: vec![SlashCommandInfo { + name: "review".into(), + description: Some("Review a PR".into()), + }], + ..Default::default() + }; + + let partial = catalog_partial_from_caps(&caps).expect("a discovered catalog projects a partial"); + let cfg = partial.config_options.expect("config_options present"); + let opts = cfg.as_array().unwrap(); + assert_eq!(opts[0]["id"], "mode"); + assert_eq!(opts[0]["currentValue"], "plan"); + assert_eq!(opts[0]["options"][0]["value"], "plan"); + assert_eq!(opts[1]["id"], "model"); + assert_eq!(opts[1]["currentValue"], "opus"); + let cmds = partial.available_commands.expect("commands present"); + assert_eq!(cmds.as_array().unwrap()[0]["name"], "review"); + + let empty = aionui_session::Capabilities::default(); + assert!( + catalog_partial_from_caps(&empty).is_none(), + "empty catalog projects nothing" + ); + } + + /// Full-access / yolo escalates the codex sandbox to `danger-full-access`; + /// read-only RESTRICTS it to `read-only` (so the FIRST turn is already locked + /// down — the SetMode permission profile only lands on the NEXT turn); the + /// workspace/auto middle tier keeps None ⇒ workspace-write. + #[test] + fn codex_sandbox_maps_full_access_and_read_only_modes() { + // #608 canonical full-access id (migration 021 + normalize_requested_mode). + assert_eq!( + codex_sandbox_for_mode(Some("agent-full-access")), + Some("danger-full-access") + ); + // Plan B legacy bare token (pre-021 persisted data). + assert_eq!(codex_sandbox_for_mode(Some("full-access")), Some("danger-full-access")); + // The colon profile id (e.g. a readback that skipped bare-mapping) stays recognized. + assert_eq!( + codex_sandbox_for_mode(Some(":danger-full-access")), + Some("danger-full-access") + ); + assert_eq!( + codex_sandbox_for_mode(Some("yoloNoSandbox")), + Some("danger-full-access") + ); + assert_eq!( + codex_sandbox_for_mode(Some(" :danger-full-access ")), + Some("danger-full-access") + ); + // read-only RESTRICTS the sandbox at OPEN time (regression fix: seeding this + // at thread/start is what makes the first-turn write actually blocked; the + // SetMode permission profile alone applies too late). Both the bare token and + // the colon id (and surrounding whitespace) are recognized. + assert_eq!(codex_sandbox_for_mode(Some("read-only")), Some("read-only")); + assert_eq!(codex_sandbox_for_mode(Some(":read-only")), Some("read-only")); + assert_eq!(codex_sandbox_for_mode(Some(" read-only ")), Some("read-only")); + // The workspace/auto middle tier keeps the safe workspace-write default. + assert_eq!(codex_sandbox_for_mode(Some(":workspace")), None); + assert_eq!(codex_sandbox_for_mode(Some("plan")), None); + assert_eq!(codex_sandbox_for_mode(Some("default")), None); + assert_eq!(codex_sandbox_for_mode(None), None); + } + + /// Sibling of the sandbox map: a full-access / yolo mode drops approvals + /// (→ "never"); everything else stays at on-request (None). Ported verbatim. + #[test] + fn codex_approval_maps_only_full_access_modes() { + assert_eq!(codex_approval_for_mode(Some(":danger-full-access")), Some("never")); + assert_eq!(codex_approval_for_mode(Some("agent-full-access")), Some("never")); + assert_eq!(codex_approval_for_mode(Some("full-access")), Some("never")); + assert_eq!(codex_approval_for_mode(Some("yoloNoSandbox")), Some("never")); + assert_eq!(codex_approval_for_mode(Some(" :danger-full-access ")), Some("never")); + assert_eq!(codex_approval_for_mode(Some(":read-only")), None); + assert_eq!(codex_approval_for_mode(Some(":workspace")), None); + assert_eq!(codex_approval_for_mode(Some("plan")), None); + assert_eq!(codex_approval_for_mode(Some("default")), None); + assert_eq!(codex_approval_for_mode(None), None); + } + + #[test] + fn session_server_to_spec_collapses_4_transports_to_3_and_sorts_kv() { + use aionui_api_types::{SessionMcpServer, SessionMcpTransport}; + use aionui_session::McpTransport; + use std::collections::HashMap; + + let stdio = session_server_to_spec(&SessionMcpServer { + id: "1".into(), + name: "fs".into(), + transport: SessionMcpTransport::Stdio { + command: "/node".into(), + args: vec!["s.js".into()], + env: HashMap::from([("B".into(), "2".into()), ("A".into(), "1".into())]), + }, + }); + assert_eq!(stdio.name, "fs"); + match stdio.transport { + McpTransport::Stdio { command, env, .. } => { + assert_eq!(command, "/node"); + assert_eq!( + env, + vec![("A".into(), "1".into()), ("B".into(), "2".into())], + "env sorted by key" + ); + } + other => panic!("expected Stdio, got {other:?}"), + } + + for t in [ + SessionMcpTransport::StreamableHttp { + url: "https://x".into(), + headers: HashMap::new(), + }, + SessionMcpTransport::Http { + url: "https://x".into(), + headers: HashMap::new(), + }, + ] { + let spec = session_server_to_spec(&SessionMcpServer { + id: "2".into(), + name: "h".into(), + transport: t, + }); + assert!( + matches!(spec.transport, McpTransport::Http { .. }), + "Http+StreamableHttp → Http" + ); + } + } +} + +#[cfg(test)] +mod translate_tests { + use super::*; + use crate::protocol::events::tool_call::{ToolCallEventData, ToolCallStatus}; + use aionui_session::PermissionKind; + + fn tool_call(call_id: &str, name: &str, status: ToolCallStatus) -> AgentStreamEvent { + AgentStreamEvent::ToolCall(ToolCallEventData { + call_id: call_id.into(), + name: name.into(), + args: serde_json::Value::Null, + status, + input: None, + output: None, + description: None, + }) + } + + // The bug: a tool's terminal ToolResult frame (and any codex ToolOutputDelta) + // carries no name, so — persisted by upsert on call_id — it clobbered the tool + // name to "" and the frontend rendered a nameless tool line. `stamp_tool_name` + // remembers the name from the Running frame and refills the empty follow-ups. + #[test] + fn tool_name_survives_the_empty_name_result_frame() { + let mut names = std::collections::HashMap::new(); + + // Running frame carries the name; the map learns it. + let mut running = tool_call("call-1", "Read", ToolCallStatus::Running); + stamp_tool_name(&mut names, &mut running); + assert_eq!(names.get("call-1").map(String::as_str), Some("Read")); + + // Codex live-output frame arrives with an empty name → refilled. + let mut delta = tool_call("call-1", "", ToolCallStatus::Running); + stamp_tool_name(&mut names, &mut delta); + let AgentStreamEvent::ToolCall(d) = &delta else { + unreachable!() + }; + assert_eq!(d.name, "Read", "live-output frame must keep the name"); + + // Terminal result frame arrives with an empty name → refilled, NOT clobbered. + let mut result = tool_call("call-1", "", ToolCallStatus::Completed); + stamp_tool_name(&mut names, &mut result); + let AgentStreamEvent::ToolCall(r) = &result else { + unreachable!() + }; + assert_eq!(r.name, "Read", "result frame must keep the name, not go blank"); + assert_eq!(r.status, ToolCallStatus::Completed); + } + + // A result frame for a call_id we never saw a name for stays empty (no panic, + // no cross-call bleed) — and a different call_id is never cross-filled. + #[test] + fn stamp_tool_name_does_not_bleed_across_call_ids() { + let mut names = std::collections::HashMap::new(); + let mut a = tool_call("call-a", "Bash", ToolCallStatus::Running); + stamp_tool_name(&mut names, &mut a); + + let mut orphan = tool_call("call-b", "", ToolCallStatus::Completed); + stamp_tool_name(&mut names, &mut orphan); + let AgentStreamEvent::ToolCall(o) = &orphan else { + unreachable!() + }; + assert_eq!(o.name, "", "unknown call_id must not inherit another tool's name"); + } + + #[test] + fn permission_surfaces_as_acp_permission_keyed_on_request_id() { + let events = translate_event( + SessionEvent::Permission { + request_id: "req-42".into(), + kind: PermissionKind::Tool, + metadata: None, + tool_name: Some("Bash".into()), + input: Some(serde_json::json!({"command": "ls"})), + }, + "conv-1", + false, + ); + assert_eq!(events.len(), 1, "permission must project to exactly one card"); + let crate::protocol::events::AgentStreamEvent::AcpPermission( + crate::protocol::events::AcpPermissionEventData::Request(req), + ) = &events[0] + else { + panic!("expected AcpPermission Request, got {:?}", events[0]); + }; + // The confirm() path answers AnswerPermission keyed on this id — it MUST + // equal the originating request_id or the approval never resolves. + assert_eq!(req.tool_call.tool_call_id, "req-42"); + assert_eq!(req.tool_call.title.as_deref(), Some("Bash")); + assert!(req.tool_call.raw_input.is_some(), "tool input rides as raw_input"); + // A NON-AskUserQuestion tool approval offers the generic Allow/AllowAlways/Reject. + assert_eq!(req.options.len(), 3); + let ids: Vec<&str> = req.options.iter().map(|o| o.option_id.as_str()).collect(); + assert_eq!(ids, vec!["allow", "allow_always", "reject"]); + } + + // An AskUserQuestion permission must surface the QUESTION's own option labels as + // the card choices (so the user answers the question), NOT a generic allow/deny. + // This is the fix for "AskUserQuestion rendered as an allow box": the frontend + // card renders whatever options[] the backend sends. + #[test] + fn ask_user_question_surfaces_question_options_not_allow_deny() { + let events = translate_event( + SessionEvent::Permission { + request_id: "req-ask".into(), + kind: PermissionKind::Tool, + metadata: None, + tool_name: Some("AskUserQuestion".into()), + input: Some(serde_json::json!({ + "questions": [{ + "question": "Which database?", + "header": "DB", + "multiSelect": false, + "options": [ + {"label": "Postgres", "description": "relational"}, + {"label": "SQLite", "description": "embedded"} + ] + }] + })), + }, + "conv-1", + false, + ); + let crate::protocol::events::AgentStreamEvent::AcpPermission( + crate::protocol::events::AcpPermissionEventData::Request(req), + ) = &events[0] + else { + panic!("expected AcpPermission Request, got {:?}", events[0]); + }; + let ids: Vec<&str> = req.options.iter().map(|o| o.option_id.as_str()).collect(); + let names: Vec<&str> = req.options.iter().map(|o| o.name.as_str()).collect(); + // The card offers the question's labels — option_id == label so confirm() can + // forward the pick as the AnswerPermission answer label. + assert_eq!( + ids, + vec!["Postgres", "SQLite"], + "must render question options, not allow/deny" + ); + assert_eq!(names, vec!["Postgres", "SQLite"]); + assert!(!ids.contains(&"allow"), "must NOT be a generic allow/deny card"); + } + + // A malformed / optionless AskUserQuestion falls back to the generic card rather + // than rendering an unanswerable empty option list. + #[test] + fn ask_user_question_without_options_falls_back_to_generic() { + let events = translate_event( + SessionEvent::Permission { + request_id: "req-ask2".into(), + kind: PermissionKind::Tool, + metadata: None, + tool_name: Some("AskUserQuestion".into()), + input: Some(serde_json::json!({"questions": []})), + }, + "conv-1", + false, + ); + let crate::protocol::events::AgentStreamEvent::AcpPermission( + crate::protocol::events::AcpPermissionEventData::Request(req), + ) = &events[0] + else { + panic!("expected AcpPermission Request"); + }; + let ids: Vec<&str> = req.options.iter().map(|o| o.option_id.as_str()).collect(); + assert_eq!(ids, vec!["allow", "allow_always", "reject"], "fallback to generic"); + } + + #[test] + fn usage_delta_surfaces_as_context_usage() { + let events = translate_event( + SessionEvent::UsageDelta { + input_tokens: 10, + output_tokens: 20, + total_tokens: 30, + cost_usd: Some(0.5), + }, + "conv-1", + false, + ); + assert_eq!(events.len(), 1); + let crate::protocol::events::AgentStreamEvent::AcpContextUsage(v) = &events[0] else { + panic!("expected AcpContextUsage, got {:?}", events[0]); + }; + // Frontend ContextUsageIndicator reads `used` (not `total_tokens`) — the + // shape the ACP path forwards. `cost` rides as {amount,currency}. + assert_eq!(v.get("used").and_then(|x| x.as_u64()), Some(30), "used = total_tokens"); + assert_eq!( + v.get("cost").and_then(|c| c.get("amount")).and_then(|x| x.as_f64()), + Some(0.5) + ); + assert_eq!( + v.get("cost").and_then(|c| c.get("currency")).and_then(|x| x.as_str()), + Some("USD") + ); + } + + // A backend Notice (a rejected mode/model/effort set, or a codex out-of-turn + // warning/deprecation) must NOT be silently dropped at the seam — the backends emit + // it precisely so the failure is visible. It surfaces as a `Tips` frame the frontend + // already renders, carrying the notice level → TipType and the message verbatim. + #[test] + fn notice_surfaces_as_tips() { + for (level, expected) in [ + (aionui_session::NoticeLevel::Warning, TipType::Warning), + (aionui_session::NoticeLevel::Info, TipType::Info), + ] { + let events = translate_event( + SessionEvent::Notice { + level, + message: "set effort: rejected by agent".into(), + }, + "conv-1", + false, + ); + assert_eq!(events.len(), 1, "a Notice must produce exactly one Tips frame"); + let crate::protocol::events::AgentStreamEvent::Tips(tip) = &events[0] else { + panic!("expected Tips, got {:?}", events[0]); + }; + assert_eq!(tip.content, "set effort: rejected by agent"); + assert_eq!(tip.tip_type, expected, "notice level maps to tip severity"); + assert!(tip.code.is_none(), "ad-hoc notice carries no i18n code"); + } + } + + // A ConfigChanged must NOT produce any stream frame: the origin frontend's mode/ + // model pickers track selection in local state (optimistic on the PUT + its REST + // response), and an `acp_config_option` frame would fall into origin useAcpMessage's + // `default:` arm and light a spurious turn timer bar. The selection is still + // persisted separately (see persist_tests::config_changed_persists_mode_and_model). + #[test] + fn config_changed_emits_no_frame() { + let events = translate_event( + SessionEvent::ConfigChanged { + mode: Some("plan".into()), + model: Some("claude-opus-4-8".into()), + }, + "conv-1", + false, + ); + assert!( + events.is_empty(), + "ConfigChanged must emit no stream frame, got {events:?}" + ); + } + + // A codex Plan surfaces as AgentStreamEvent::Plan with the frontend's expected + // entry shape: content + snake_case status (the MessagePlan renderer ticks on + // status === 'completed'). A raw serde dump of PlanStatus would send PascalCase + // and the card would never tick — this guards the explicit mapping. + #[test] + fn plan_surfaces_with_snake_case_status() { + use aionui_session::{PlanEntry, PlanStatus}; + let events = translate_event( + SessionEvent::Plan { + entries: vec![ + PlanEntry { + content: "step one".into(), + status: PlanStatus::Completed, + priority: None, + }, + PlanEntry { + content: "step two".into(), + status: PlanStatus::InProgress, + priority: None, + }, + ], + explanation: None, + }, + "conv-1", + false, + ); + assert_eq!(events.len(), 1); + let crate::protocol::events::AgentStreamEvent::Plan(data) = &events[0] else { + panic!("expected Plan, got {:?}", events[0]); + }; + assert_eq!(data.entries.len(), 2); + assert_eq!(data.entries[0]["content"], "step one"); + assert_eq!( + data.entries[0]["status"], "completed", + "status must be snake_case for the frontend" + ); + assert_eq!(data.entries[1]["status"], "in_progress"); + } + + // A user-cancelled turn must NOT surface an error Tips (claude reports the + // interrupt as an is_error result, but the user asked for it — surfacing it pops + // a spurious red bubble on every cancel). Only a plain Finish is emitted (no Error). + #[test] + fn cancelled_turn_emits_finish_without_error() { + use aionui_session::{CancelReason, TurnOutcome}; + let events = translate_event( + SessionEvent::TurnResult { + is_error: true, + api_error_status: None, + result_text: "error_during_execution".into(), + epoch: 0, + outcome: TurnOutcome::Cancelled { + reason: CancelReason::UserCancel, + }, + }, + "conv-1", + false, + ); + assert!( + !events.iter().any(|e| matches!(e, AgentStreamEvent::Error(_))), + "a cancelled turn must not emit an Error, got {events:?}" + ); + assert!( + events.iter().any(|e| matches!(e, AgentStreamEvent::Finish(_))), + "a cancelled turn still finishes" + ); + } + + // A genuine (non-cancel) error terminates as AgentStreamEvent::Error carrying the + // full origin error model (code/ownership/retryable), NOT a plain Tips and NOT a + // Finish (Error is itself the relay terminal). This is what lets the relay + // classify + auto-replay and the frontend render ownership/feedback. + #[test] + fn errored_turn_emits_rich_error_terminal() { + use aionui_session::{StopReason, TurnOutcome}; + let events = translate_event( + SessionEvent::TurnResult { + is_error: true, + api_error_status: Some(500), + result_text: "upstream exploded".into(), + epoch: 0, + outcome: TurnOutcome::Completed { + stop_reason: StopReason::EndTurn, + }, + }, + "conv-1", + false, + ); + assert_eq!( + events.len(), + 1, + "a real error is a single Error terminal, got {events:?}" + ); + let AgentStreamEvent::Error(data) = &events[0] else { + panic!("expected Error terminal, got {:?}", events[0]); + }; + // Classified through the origin error path → carries a code + ownership + + // retryable (not a bare message). The exact code depends on the classifier; + // the contract is that these fields are POPULATED, not None. + assert!(!data.message.is_empty()); + assert!(data.code.is_some(), "error must carry a classified code"); + assert!(data.retryable.is_some(), "error must carry a retryable flag"); + // Must NOT also emit a Finish (Error is the terminal). + assert!(!events.iter().any(|e| matches!(e, AgentStreamEvent::Finish(_)))); + } + + // A mid-turn process crash (Detached with a signal / non-zero / unknown exit and + // NO prior terminal result) surfaces as a rich AgentStreamEvent::Error carrying the + // legacy `UserAgentDisconnected` classification (code + retryable + ownership), with + // the allowlisted redacted_summary appended to the message. This restores the ACP + // path's `AcpError::Disconnected` terminal the direct-CLI bridge had collapsed to a + // bare Finish (a dead CLI used to render as a normal empty completion). + #[test] + fn detached_crash_surfaces_as_rich_disconnect_error() { + use aionui_session::ExitStatusLite; + let events = translate_event( + SessionEvent::Detached { + exit: Some(ExitStatusLite { + code: None, + signal: Some(9), + }), + redacted_summary: Some("process killed (SIGKILL)".into()), + }, + // No terminal TurnResult was seen this turn → a genuine mid-turn crash. + "conv-1", + false, + ); + assert_eq!(events.len(), 1, "a crash is a single Error terminal, got {events:?}"); + let AgentStreamEvent::Error(data) = &events[0] else { + panic!("expected Error terminal, got {:?}", events[0]); + }; + // Classified through the SAME path legacy used → carries code + retryable, and + // the allowlisted summary rides the user-facing message. + assert!(data.code.is_some(), "crash must carry a classified code"); + assert!(data.retryable.is_some(), "crash must carry a retryable flag"); + assert!( + data.message.contains("process killed (SIGKILL)"), + "redacted_summary must ride the message, got {:?}", + data.message + ); + assert!( + !events.iter().any(|e| matches!(e, AgentStreamEvent::Finish(_))), + "Error is the terminal — must NOT also emit Finish" + ); + } + + // A Detached that ARRIVES AFTER a terminal TurnResult is an absorbed teardown (the + // reducer's I10 / `crash_outcome → FollowResult`), NOT a crash — it must end with a + // plain Finish, never an error card (otherwise every normal turn's process exit + // would pop a spurious "disconnected" error). + #[test] + fn detached_after_terminal_result_is_plain_finish() { + use aionui_session::ExitStatusLite; + let events = translate_event( + SessionEvent::Detached { + exit: Some(ExitStatusLite { + code: Some(1), + signal: None, + }), + redacted_summary: Some("exited".into()), + }, + // The turn already reached its terminal TurnResult. + "conv-1", + true, + ); + assert!( + !events.iter().any(|e| matches!(e, AgentStreamEvent::Error(_))), + "a post-terminal Detached must not emit an Error, got {events:?}" + ); + assert!( + events.iter().any(|e| matches!(e, AgentStreamEvent::Finish(_))), + "an absorbed teardown still finishes, got {events:?}" + ); + } + + // A clean exit-0 with no result (a genuinely blank turn) is NOT a crash — it ends + // with a plain Finish (the empty-turn Tip rides the pump's status match, not here). + #[test] + fn detached_clean_exit_zero_is_plain_finish() { + use aionui_session::ExitStatusLite; + let events = translate_event( + SessionEvent::Detached { + exit: Some(ExitStatusLite { + code: Some(0), + signal: None, + }), + redacted_summary: None, + }, + "conv-1", + false, + ); + assert!( + !events.iter().any(|e| matches!(e, AgentStreamEvent::Error(_))), + "a clean exit-0 must not emit an Error, got {events:?}" + ); + assert!( + events.iter().any(|e| matches!(e, AgentStreamEvent::Finish(_))), + "a clean exit-0 finishes, got {events:?}" + ); + } + + // --- empty-turn (blank-reply) diagnostic Tip, mirroring the ACP path --- + + fn tip_code(outcome: aionui_session::TurnOutcome) -> Option<(TipType, String)> { + empty_turn_tip(&outcome).map(|t| (t.tip_type, t.code.unwrap())) + } + + #[test] + fn empty_turn_endturn_is_info_generic_code() { + use aionui_session::{StopReason, TurnOutcome}; + // Both the legacy default `EndTurn` and the modern `Completed{EndTurn}` map + // to the informational "no reply" note. + for outcome in [ + TurnOutcome::EndTurn, + TurnOutcome::Completed { + stop_reason: StopReason::EndTurn, + }, + ] { + assert_eq!(tip_code(outcome), Some((TipType::Info, "ACP_EMPTY_TURN".to_owned()))); + } + } + + #[test] + fn empty_turn_truncation_and_refusal_map_to_acp_warning_codes() { + use aionui_session::{StopReason, TruncationKind, TurnOutcome}; + // Exactly the codes the ACP path emits (agent_session_flow.rs empty_finish_tip_code). + assert_eq!( + tip_code(TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::MaxTokens), + }), + Some((TipType::Warning, "ACP_EMPTY_TURN_MAX_TOKENS".to_owned())) + ); + assert_eq!( + tip_code(TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::MaxTurns), + }), + Some((TipType::Warning, "ACP_EMPTY_TURN_MAX_TURN_REQUESTS".to_owned())) + ); + assert_eq!( + tip_code(TurnOutcome::Completed { + stop_reason: StopReason::Refused { category: None }, + }), + Some((TipType::Warning, "ACP_EMPTY_TURN_REFUSAL".to_owned())) + ); + } + + #[test] + fn empty_turn_other_truncation_and_failed_fall_back_to_generic_warning() { + use aionui_session::{StopReason, TruncationKind, TurnOutcome}; + // Truncation kinds with no dedicated ACP code, plus a clean Failed, still + // warn the user rather than silently rendering an empty bubble. + for outcome in [ + TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::ContextWindow), + }, + TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::Budget), + }, + TurnOutcome::Failed, + ] { + assert_eq!(tip_code(outcome), Some((TipType::Warning, "ACP_EMPTY_TURN".to_owned()))); + } + } + + #[test] + fn empty_turn_cancelled_never_tips() { + use aionui_session::{CancelReason, TurnOutcome}; + // A user interrupt is not a blank reply — no spurious tip. + assert!( + empty_turn_tip(&TurnOutcome::Cancelled { + reason: CancelReason::UserCancel, + }) + .is_none() + ); + } + + #[test] + fn user_visible_output_predicate_matches_renderable_frames() { + // Frames that render in chat count as visible; lifecycle/metadata frames do not. + assert!(event_is_user_visible_output(&AgentStreamEvent::Text(TextEventData { + content: "hi".into(), + }))); + assert!(event_is_user_visible_output(&tool_call( + "c", + "Read", + ToolCallStatus::Running + ))); + assert!(!event_is_user_visible_output(&AgentStreamEvent::Finish( + FinishEventData::default() + ))); + assert!(!event_is_user_visible_output(&AgentStreamEvent::SegmentBreak)); + } +} + +#[cfg(test)] +mod persist_tests { + //! The pump's persistence hookup — the writes the legacy ACP path performed via + //! `AcpSessionSyncService` but which this direct-CLI path must do itself. Without + //! these the resume anchor + mode/model precedence source are never written. + use super::*; + use aionui_db::{CreateAcpSessionParams, IAcpSessionRepository, SqliteAcpSessionRepository, init_database_memory}; + + // Returns both the repo and the owning Database — the caller binds the Database + // for the test's lifetime (the cloned SqlitePool keeps the in-memory DB alive). + async fn seeded_repo() -> (Arc, aionui_db::Database) { + let db = init_database_memory().await.unwrap(); + let repo: Arc = Arc::new(SqliteAcpSessionRepository::new(db.pool().clone())); + repo.create(&CreateAcpSessionParams { + conversation_id: "conv-1", + agent_source: "builtin", + agent_id: "claude", + }) + .await + .unwrap(); + (repo, db) + } + + #[tokio::test] + async fn backend_bound_persists_resume_anchor() { + let (repo, _db) = seeded_repo().await; + persist_side_effects( + repo.as_ref(), + "conv-1", + &SessionEvent::BackendBound { + backend_session_id: Some("bsid-abc".into()), + }, + ) + .await; + let row = repo.get("conv-1").await.unwrap().expect("row exists"); + assert_eq!( + row.session_id.as_deref(), + Some("bsid-abc"), + "BackendBound must write the resume anchor build_session_instance reads back" + ); + } + + #[tokio::test] + async fn backend_bound_none_does_not_clobber_anchor() { + let (repo, _db) = seeded_repo().await; + repo.update_session_id("conv-1", "bsid-existing").await.unwrap(); + persist_side_effects( + repo.as_ref(), + "conv-1", + &SessionEvent::BackendBound { + backend_session_id: None, + }, + ) + .await; + let row = repo.get("conv-1").await.unwrap().expect("row exists"); + assert_eq!( + row.session_id.as_deref(), + Some("bsid-existing"), + "BackendBound{{None}} (lost-backend self-heal) must leave the stored anchor intact" + ); + } + + #[tokio::test] + async fn config_changed_persists_mode_and_model() { + let (repo, _db) = seeded_repo().await; + persist_side_effects( + repo.as_ref(), + "conv-1", + &SessionEvent::ConfigChanged { + mode: Some("plan".into()), + model: Some("claude-opus-4-8".into()), + }, + ) + .await; + let state = repo.load_runtime_state("conv-1").await.unwrap().expect("runtime state"); + assert_eq!(state.current_mode_id.as_deref(), Some("plan")); + assert_eq!(state.current_model_id.as_deref(), Some("claude-opus-4-8")); + } + + // #4: a `set_config_option("effort", ...)` must persist the level into + // config_selections — claude emits NO ConfigChanged for effort, so unless + // set_config_option writes it directly, effort is lost across respawn/resume. + // This drives the real chokepoint (a task built around a NoStreamBackend + the + // seeded repo) and asserts the level lands in the persisted config_selections. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn set_config_option_effort_persists_into_config_selections() { + use super::pump_tests::StaticCapsBackend; + let (repo, _db) = seeded_repo().await; + let backend: Arc = Arc::new(StaticCapsBackend); + let task = SessionAgentTask::new( + AgentType::Acp, + "conv-1".into(), + "/w".into(), + backend, + Some(repo.clone()), + ); + + let resp = task.set_config_option("effort", "high").await.unwrap(); + assert!( + matches!( + resp.confirmation, + aionui_api_types::ConfigOptionConfirmation::CommandAck + ), + "effort reports CommandAck (no picker current_value to observe)" + ); + + // Persisted under the effort key so build_session_instance can re-apply it. + let state = repo.load_runtime_state("conv-1").await.unwrap().expect("runtime state"); + let selections: std::collections::HashMap = serde_json::from_str( + state + .config_selections_json + .as_deref() + .expect("config_selections persisted"), + ) + .unwrap(); + assert_eq!( + selections.get(EFFORT_CONFIG_KEY).map(String::as_str), + Some("high"), + "the chosen effort must be persisted into config_selections" + ); + } + + // A backend that advertises per-model reasoning efforts (claude `supportedEffortLevels`), + // used to prove the effort axis is surfaced to the frontend picker. The prior gap: + // `get_config_options` emitted only mode+model, so the origin frontend's + // `deriveSelectOption(..., 'thought_level', ['reasoning_effort'])` found nothing and the + // top-right selector never showed a thinking/effort group even though claude advertises + // it and the backend can set it. + use aionui_session::{ + Admission, BackendError, Capabilities, Command, CommandReceipt, SessionBackend, SessionEnvelope, + }; + use futures_util::stream::BoxStream; + + struct EffortCapsBackend; + + #[async_trait::async_trait] + impl SessionBackend for EffortCapsBackend { + async fn dispatch(&self, _c: Command) -> Result { + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: 0, + }) + } + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + use futures_util::StreamExt as _; + futures_util::stream::empty().boxed() + } + fn capabilities(&self) -> Capabilities { + use aionui_session::ModelInfo; + Capabilities { + available_models: vec![ + ModelInfo { + id: "opus".into(), + name: "Opus".into(), + description: None, + reasoning_efforts: vec!["low".into(), "medium".into(), "high".into(), "max".into()], + }, + ModelInfo { + id: "haiku".into(), + name: "Haiku".into(), + description: None, + reasoning_efforts: vec![], + }, + ], + current_model: Some("opus".into()), + ..Default::default() + } + } + } + + // The effort ("thought level") axis MUST be surfaced as a config option so the origin + // frontend renders it in the top-right model selector (parity with the ACP path's + // `thought_level` option). Asserts the exact shape the frontend keys off: category + // `thought_level`, id `reasoning_effort`, and the current model's advertised levels. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn get_config_options_surfaces_reasoning_effort_for_current_model() { + let backend: Arc = Arc::new(EffortCapsBackend); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let snapshot = task.get_config_options().await.unwrap(); + let effort = snapshot + .config_options + .iter() + .find(|o| o.category.as_deref() == Some("thought_level")) + .expect("effort axis must be surfaced as a thought_level config option"); + assert_eq!( + effort.id, "reasoning_effort", + "canonical id the frontend fallback matches" + ); + assert_eq!(effort.option_type, "select"); + let values: Vec<&str> = effort.options.iter().map(|o| o.value.as_str()).collect(); + assert_eq!( + values, + vec!["low", "medium", "high", "max"], + "the current model's advertised efforts" + ); + } + + // A model with no advertised efforts (claude `haiku`) must NOT get an effort option — + // an empty select would render a dead, choice-less group. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn get_config_options_omits_effort_when_current_model_has_none() { + struct HaikuCurrentBackend; + #[async_trait::async_trait] + impl SessionBackend for HaikuCurrentBackend { + async fn dispatch(&self, _c: Command) -> Result { + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: 0, + }) + } + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + use futures_util::StreamExt as _; + futures_util::stream::empty().boxed() + } + fn capabilities(&self) -> Capabilities { + use aionui_session::ModelInfo; + Capabilities { + available_models: vec![ModelInfo { + id: "haiku".into(), + name: "Haiku".into(), + description: None, + reasoning_efforts: vec![], + }], + current_model: Some("haiku".into()), + ..Default::default() + } + } + } + let backend: Arc = Arc::new(HaikuCurrentBackend); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let snapshot = task.get_config_options().await.unwrap(); + assert!( + snapshot + .config_options + .iter() + .all(|o| o.category.as_deref() != Some("thought_level")), + "a model with no advertised efforts must not surface an (empty) effort option" + ); + } + + // Setting effort must read back as Observed with the requested level (the frontend's + // `hasObservedValue` contract), driven by the optimistic override — claude emits no + // effort echo, so without the override the switch would downgrade to command_ack and + // the frontend would reject it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn set_config_option_effort_returns_observed_via_override() { + let (repo, _db) = seeded_repo().await; + let backend: Arc = Arc::new(EffortCapsBackend); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, Some(repo)); + let resp = task.set_config_option("reasoning_effort", "high").await.unwrap(); + assert!( + matches!(resp.confirmation, aionui_api_types::ConfigOptionConfirmation::Observed), + "effort switch must be Observed (optimistic override), not command_ack" + ); + let effort = resp + .config_options + .as_ref() + .and_then(|opts| opts.iter().find(|o| o.category.as_deref() == Some("thought_level"))) + .expect("effort option in the observed snapshot"); + assert_eq!( + effort.current_value.as_deref(), + Some("high"), + "the requested level is highlighted" + ); + } + + // ── Defect 2: dead-resume-anchor self-heal ──────────────────────────── + // A turn that fails *because* the stored backend session no longer resolves must + // NULL that anchor, or every subsequent send re-resumes the same dead id and the + // conversation wedges forever. This restores the self-heal the direct-CLI path + // dropped (clean-slate `Orchestrator` BackendBound{None}; legacy ACP + // rebuild_after_session_not_found → clear_session_id). + + fn errored_turn(text: &str) -> SessionEvent { + use aionui_session::{StopReason, TurnOutcome}; + SessionEvent::TurnResult { + is_error: true, + api_error_status: None, + result_text: text.into(), + epoch: 0, + outcome: TurnOutcome::Completed { + stop_reason: StopReason::EndTurn, + }, + } + } + + #[tokio::test] + async fn no_conversation_found_clears_dead_anchor() { + let (repo, _db) = seeded_repo().await; + repo.update_session_id("conv-1", "dead-sid").await.unwrap(); + persist_side_effects( + repo.as_ref(), + "conv-1", + &errored_turn("No conversation found with session ID dead-sid"), + ) + .await; + let row = repo.get("conv-1").await.unwrap().expect("row exists"); + assert_eq!( + row.session_id, None, + "an unrecoverable resume error must null the dead anchor so the next turn opens Fresh" + ); + } + + #[tokio::test] + async fn error_during_execution_clears_dead_anchor() { + let (repo, _db) = seeded_repo().await; + repo.update_session_id("conv-1", "dead-sid").await.unwrap(); + persist_side_effects(repo.as_ref(), "conv-1", &errored_turn("error_during_execution")).await; + let row = repo.get("conv-1").await.unwrap().expect("row exists"); + assert_eq!( + row.session_id, None, + "error_during_execution is a structural resume failure" + ); + } + + #[tokio::test] + async fn ordinary_error_keeps_anchor() { + let (repo, _db) = seeded_repo().await; + repo.update_session_id("conv-1", "live-sid").await.unwrap(); + // A normal tool/turn error is NOT a resume failure — the anchor is still good. + persist_side_effects( + repo.as_ref(), + "conv-1", + &errored_turn("the Bash tool exited with code 1"), + ) + .await; + let row = repo.get("conv-1").await.unwrap().expect("row exists"); + assert_eq!( + row.session_id.as_deref(), + Some("live-sid"), + "an ordinary error must NOT clear a still-valid resume anchor" + ); + } + + #[tokio::test] + async fn cancelled_turn_keeps_anchor_even_with_matching_text() { + use aionui_session::{CancelReason, TurnOutcome}; + let (repo, _db) = seeded_repo().await; + repo.update_session_id("conv-1", "live-sid").await.unwrap(); + // claude reports a user interrupt as is_error with cancel-noise text; the + // anchor is still good, so a cancel must never trigger the self-heal. + persist_side_effects( + repo.as_ref(), + "conv-1", + &SessionEvent::TurnResult { + is_error: true, + api_error_status: None, + result_text: "error_during_execution".into(), + epoch: 0, + outcome: TurnOutcome::Cancelled { + reason: CancelReason::UserCancel, + }, + }, + ) + .await; + let row = repo.get("conv-1").await.unwrap().expect("row exists"); + assert_eq!( + row.session_id.as_deref(), + Some("live-sid"), + "a user-cancelled turn must NOT clear the anchor" + ); + } + + // Pure classification matrix for the FCIS core, independent of the DB. + #[test] + fn is_dead_resume_anchor_matrix() { + use aionui_session::{CancelReason, StopReason, TurnOutcome}; + let completed = |is_error: bool, text: &str| SessionEvent::TurnResult { + is_error, + api_error_status: None, + result_text: text.into(), + epoch: 0, + outcome: TurnOutcome::Completed { + stop_reason: StopReason::EndTurn, + }, + }; + // Structural resume failures → dead anchor. + assert!(is_dead_resume_anchor(&completed( + true, + "No conversation found: dead-sid" + ))); + assert!(is_dead_resume_anchor(&completed(true, "error_during_execution"))); + // is_error:false is never a dead anchor, even with matching text. + assert!(!is_dead_resume_anchor(&completed(false, "No conversation found"))); + // Ordinary error text is not a resume failure. + assert!(!is_dead_resume_anchor(&completed(true, "tool call failed"))); + // A user-cancel is excluded even when the noise text matches. + assert!(!is_dead_resume_anchor(&SessionEvent::TurnResult { + is_error: true, + api_error_status: None, + result_text: "error_during_execution".into(), + epoch: 0, + outcome: TurnOutcome::Cancelled { + reason: CancelReason::UserCancel, + }, + })); + // Non-TurnResult events are never dead anchors. + assert!(!is_dead_resume_anchor(&SessionEvent::BackendBound { + backend_session_id: Some("x".into()), + })); + } +} + +#[cfg(test)] +mod pump_tests { + //! End-to-end pump tests over a scripted `SessionBackend`: they assert the + //! forwarded `AgentStreamEvent` sequence for a realistic claude event stream, + //! locking in the ACP-alignment fixes found by the live frame-by-frame A/B + //! (Start emitted by send_message before dispatch; opening ConfigChanged + //! suppressed; Finish carries the CLI session id learned from BackendBound). + use super::*; + use aionui_session::{ + Admission, BackendError, Capabilities, Command, CommandReceipt, SessionBackend, SessionEnvelope, SessionEvent, + }; + use futures_util::stream::BoxStream; + + /// Emits a fixed script on `events()`; `dispatch(Send)` admits a turn. + struct ScriptBackend(Vec); + + #[async_trait::async_trait] + impl SessionBackend for ScriptBackend { + async fn dispatch(&self, c: Command) -> Result { + let admission = match c { + Command::Send { .. } => Admission::Started, + _ => Admission::NoTurn, + }; + Ok(CommandReceipt { + accepted: true, + admission, + turn_gen: 1, + }) + } + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + use futures_util::StreamExt as _; + futures_util::stream::iter(self.0.clone()).boxed() + } + fn capabilities(&self) -> Capabilities { + Capabilities::default() + } + } + + fn env(event: SessionEvent) -> SessionEnvelope { + SessionEnvelope { + session_id: "conv-1".into(), + turn_gen: 1, + event, + } + } + + /// Like `ScriptBackend`, but holds every scripted frame until its `gate` is + /// released — so a test can `subscribe()` to the task BEFORE any frame is + /// pumped. This deterministically closes a subscribe-vs-pump race: the pump is + /// spawned inside `build()` (it calls `events()` and starts polling at once), and + /// on a `multi_thread` runtime it can otherwise emit — and DROP — frames before the + /// test's `subscribe()` runs, because the broadcast channel keeps no pre-subscribe + /// buffer. `drain_script` subscribes first, THEN releases the gate. This mirrors + /// production, where the backend's event stream is subscribed at open but stays + /// silent until the CLI emits (well after the frontend's WS subscribe). + struct GatedScriptBackend { + script: Vec, + gate: Arc, + } + + #[async_trait::async_trait] + impl SessionBackend for GatedScriptBackend { + async fn dispatch(&self, c: Command) -> Result { + let admission = match c { + Command::Send { .. } => Admission::Started, + _ => Admission::NoTurn, + }; + Ok(CommandReceipt { + accepted: true, + admission, + turn_gen: 1, + }) + } + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + use futures_util::StreamExt as _; + let gate = self.gate.clone(); + let script = self.script.clone(); + // First poll parks on the gate; only once released does the scripted + // sequence flow — so no frame can predate the test's subscribe(). + futures_util::stream::once(async move { gate.notified().await }) + .flat_map(move |_| futures_util::stream::iter(script.clone())) + .boxed() + } + fn capabilities(&self) -> Capabilities { + Capabilities::default() + } + } + + // Build a task over a gated script, subscribe, THEN release the gate, and collect + // every frame the task forwards until its (finite) event stream drains. Subscribing + // before releasing is what makes the collection deterministic — see + // `GatedScriptBackend`. + async fn drain_script(script: Vec) -> Vec { + let gate = Arc::new(tokio::sync::Notify::new()); + let backend: Arc = Arc::new(GatedScriptBackend { + script, + gate: gate.clone(), + }); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let mut rx = crate::agent_task::IAgentTask::subscribe(task.as_ref()); + // Release only AFTER subscribing: the pump cannot emit before we listen. + gate.notify_one(); + let mut out = Vec::new(); + // The scripted stream is finite; once the pump drains it, no more frames + // arrive, so a short bounded poll settles the collection (no live agent). + while let Ok(Ok(ev)) = tokio::time::timeout(std::time::Duration::from_millis(300), rx.recv()).await { + out.push(ev); + } + // Keep the task (hence the backend + pump) alive through the whole drain. + drop(task); + out + } + + fn frame_name(ev: &AgentStreamEvent) -> &'static str { + match ev { + AgentStreamEvent::Start(_) => "start", + AgentStreamEvent::Text(_) => "content", + AgentStreamEvent::Finish(_) => "finish", + AgentStreamEvent::AcpConfigOption(_) => "config", + AgentStreamEvent::AcpContextUsage(_) => "usage", + AgentStreamEvent::SegmentBreak => "SegmentBreak", + _ => "other", + } + } + + // A ConfigChanged never produces a stream frame (it would fall into origin + // useAcpMessage's `default:` arm and light a spurious timer bar), and the + // BackendBound session id still reaches the Finish frame. (Mirrors the live A/B + // finding: the session path must NOT emit a stray acp_config_option.) + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn config_change_emits_no_frame_and_bound_id_reaches_finish() { + let script = vec![ + env(SessionEvent::BackendBound { + backend_session_id: Some("sid-xyz".into()), + }), + env(SessionEvent::ConfigChanged { + mode: Some("default".into()), + model: None, + }), + env(SessionEvent::MessageDelta { + item_id: "m".into(), + text: "hi".into(), + }), + env(SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: String::new(), + epoch: 0, + outcome: aionui_session::TurnOutcome::EndTurn, + }), + ]; + let frames = drain_script(script).await; + let seq: Vec<&str> = frames.iter().map(frame_name).collect(); + // No leading "config"; the turn body + terminal come through. + assert!( + !seq.contains(&"config"), + "opening ConfigChanged must be suppressed, got {seq:?}" + ); + assert_eq!(seq, vec!["content", "finish"], "got {seq:?}"); + // The Finish carries the CLI session id learned from BackendBound. + let finish = frames.iter().rev().find(|f| matches!(f, AgentStreamEvent::Finish(_))); + let AgentStreamEvent::Finish(data) = finish.expect("finish present") else { + unreachable!() + }; + assert_eq!( + data.session_id.as_deref(), + Some("sid-xyz"), + "resume anchor rides Finish" + ); + } + + // THE FIX (stuck View-Steps spinner after interrupt): a tool call whose turn + // ends without a terminal `ToolResult` (user cancel / crash / dropped result) + // must be closed with a `Canceled` frame BEFORE the Finish — otherwise the + // persisted tool_call row stays status "work" forever and the frontend spinner + // (`hasRunningToolMessages`) never stops, surviving reloads. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn cancelled_turn_closes_open_tool_calls_as_canceled_before_finish() { + use aionui_session::{CancelReason, SubagentKind, TurnOutcome}; + let script = vec![ + env(SessionEvent::TurnStarted { epoch: 1 }), + env(SessionEvent::ToolCall { + tool_use_id: "call-1".into(), + name: "Bash".into(), + subagent: SubagentKind::Inline, + input: serde_json::Value::Null, + parent_tool_use_id: None, + }), + env(SessionEvent::TurnResult { + is_error: true, + api_error_status: None, + result_text: "error_during_execution".into(), + epoch: 1, + outcome: TurnOutcome::Cancelled { + reason: CancelReason::UserCancel, + }, + }), + ]; + let frames = drain_script(script).await; + let canceled_pos = frames.iter().position(|f| { + matches!(f, AgentStreamEvent::ToolCall(d) + if d.call_id == "call-1" && d.status == ToolCallStatus::Canceled && d.name == "Bash") + }); + let finish_pos = frames.iter().position(|f| matches!(f, AgentStreamEvent::Finish(_))); + let canceled_pos = canceled_pos.expect("open tool call must be closed with a Canceled frame"); + let finish_pos = finish_pos.expect("cancelled turn still finishes"); + assert!( + canceled_pos < finish_pos, + "Canceled must precede Finish (relay breaks the turn on Finish)" + ); + } + + // A tool call that DID receive its ToolResult must NOT get a trailing Canceled + // frame at turn end — only calls left open are closed. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn completed_tool_call_is_not_recanceled_at_turn_end() { + use aionui_session::SubagentKind; + let script = vec![ + env(SessionEvent::TurnStarted { epoch: 1 }), + env(SessionEvent::ToolCall { + tool_use_id: "call-1".into(), + name: "Bash".into(), + subagent: SubagentKind::Inline, + input: serde_json::Value::Null, + parent_tool_use_id: None, + }), + env(SessionEvent::ToolResult { + tool_use_id: "call-1".into(), + is_error: false, + content: vec![], + parent_tool_use_id: None, + }), + env(SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "done".into(), + epoch: 1, + outcome: aionui_session::TurnOutcome::EndTurn, + }), + ]; + let frames = drain_script(script).await; + assert!( + !frames + .iter() + .any(|f| { matches!(f, AgentStreamEvent::ToolCall(d) if d.status == ToolCallStatus::Canceled) }), + "a resolved tool call must not be closed again as Canceled, got {frames:?}" + ); + } + + // The FIX (async catalog-arrival push): a `CatalogUpdated` (the direct-CLI + // analogue of ACP's `emit_snapshot_events`) MUST project to exactly one + // `AcpConfigOption` frame carrying BOTH the model and mode categories — the + // frontend's `useAcpConfigOptions` replaces its whole snapshot on this frame, so + // omitting a sibling category would wipe that picker. Before this the catalog + // arrived ~6s after open with no upward frame, so the model selector stayed + // disabled. Unlike `ConfigChanged` (suppressed), this frame is the intended signal. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn catalog_updated_projects_config_option_with_model_and_mode() { + use aionui_session::{ModeInfo, ModelInfo}; + let script = vec![env(SessionEvent::CatalogUpdated { + models: vec![ + ModelInfo { + id: "default".into(), + name: "Default".into(), + description: None, + reasoning_efforts: Vec::new(), + }, + ModelInfo { + id: "opus".into(), + name: "Opus".into(), + description: None, + reasoning_efforts: Vec::new(), + }, + ], + modes: vec![ModeInfo { + id: "plan".into(), + name: "Plan".into(), + description: None, + }], + slash_commands: Vec::new(), + })]; + let frames = drain_script(script).await; + let config = frames + .iter() + .find_map(|f| match f { + AgentStreamEvent::AcpConfigOption(v) => Some(v), + _ => None, + }) + .expect("CatalogUpdated must project to an AcpConfigOption frame"); + let options = config + .get("config_options") + .and_then(|v| v.as_array()) + .expect("config_options array"); + let categories: Vec<&str> = options + .iter() + .filter_map(|o| o.get("category").and_then(|c| c.as_str())) + .collect(); + assert!( + categories.contains(&"model") && categories.contains(&"mode"), + "both categories must ride the snapshot (else a sibling picker is wiped), got {categories:?}" + ); + // The model category carries the parsed catalog so `canSwitch` derives true. + let model_opt = options + .iter() + .find(|o| o.get("category").and_then(|c| c.as_str()) == Some("model")) + .expect("model category"); + let model_values: Vec<&str> = model_opt + .get("options") + .and_then(|v| v.as_array()) + .expect("model options array") + .iter() + .filter_map(|o| o.get("value").and_then(|v| v.as_str())) + .collect(); + assert_eq!( + model_values, + vec!["default", "opus"], + "the parsed model ids ride the frame" + ); + } + + // The FIX (async slash-command arrival push): claude advertises its command + // list in the same late `initialize` response that carries the model/mode + // catalog. The frontend's mount-time REST read returns empty before that + // lands, and the legacy ACP path recovers via a live `AvailableCommands` + // push — so the direct-CLI pump MUST emit one too, or the `/` menu stays + // empty until a manual refetch. A CatalogUpdated carrying slash_commands + // projects to an AvailableCommands frame whose commands carry name+description. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn catalog_updated_projects_available_commands_frame() { + use aionui_session::SlashCommandInfo; + let script = vec![env(SessionEvent::CatalogUpdated { + models: Vec::new(), + modes: Vec::new(), + slash_commands: vec![ + SlashCommandInfo { + name: "compact".into(), + description: Some("Compact the conversation".into()), + }, + SlashCommandInfo { + name: "clear".into(), + description: None, + }, + ], + })]; + let frames = drain_script(script).await; + let commands = frames + .iter() + .find_map(|f| match f { + AgentStreamEvent::AvailableCommands(data) => Some(&data.commands), + _ => None, + }) + .expect("CatalogUpdated with slash_commands must project to an AvailableCommands frame"); + let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["compact", "clear"], "both commands ride the frame"); + assert_eq!( + commands[0].description, "Compact the conversation", + "description carries through" + ); + assert_eq!(commands[1].description, "", "missing description becomes empty string"); + } + + // A CatalogUpdated with NO slash commands must not emit a spurious empty + // AvailableCommands frame (which would clobber a REST-loaded menu). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn catalog_updated_without_slash_commands_emits_no_available_commands() { + use aionui_session::ModelInfo; + let script = vec![env(SessionEvent::CatalogUpdated { + models: vec![ModelInfo { + id: "opus".into(), + name: "Opus".into(), + description: None, + reasoning_efforts: Vec::new(), + }], + modes: Vec::new(), + slash_commands: Vec::new(), + })]; + let frames = drain_script(script).await; + assert!( + !frames + .iter() + .any(|f| matches!(f, AgentStreamEvent::AvailableCommands(_))), + "empty slash_commands must not emit an AvailableCommands frame" + ); + } + + // send_message emits Start (before dispatch) stamped with the learned session id, + // and PromptAccepted does NOT double-emit a Start. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn send_message_emits_single_leading_start_with_session_id() { + // Pre-seed the backend-bound id via a script event, then let the pump learn it. + let backend: Arc = Arc::new(ScriptBackend(vec![env(SessionEvent::BackendBound { + backend_session_id: Some("sid-abc".into()), + })])); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + // Let the pump process the BackendBound so session_id is known. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let mut rx = crate::agent_task::IAgentTask::subscribe(task.as_ref()); + crate::agent_task::IAgentTask::send_message( + task.as_ref(), + SendMessageData { + content: "hi".into(), + msg_id: "m1".into(), + turn_id: None, + files: Vec::new(), + inject_skills: Vec::new(), + }, + ) + .await + .unwrap(); + // The very next frame must be exactly one Start, carrying the session id. + let first = tokio::time::timeout(std::time::Duration::from_millis(300), rx.recv()) + .await + .expect("a frame") + .expect("ok"); + let AgentStreamEvent::Start(data) = first else { + panic!("expected Start first, got {}", frame_name(&first)); + }; + assert_eq!(data.session_id.as_deref(), Some("sid-abc")); + } + + // claude's non-blocking Workflow turn emits MULTIPLE `result` frames: a LAUNCH + // result while subagents still run, then a TERMINAL result after every + // `task_notification{completed}`. The pump must suppress the launch result's + // Finish (else the relay closes and the workflow's completion message is lost) + // and forward exactly ONE Finish — the terminal one, after the workflow drains. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn workflow_launch_result_finish_is_suppressed_until_workflow_completes() { + use aionui_session::SubagentStatus; + let script = vec![ + env(SessionEvent::ToolCall { + tool_use_id: "toolu_wf".into(), + name: "Task".into(), + subagent: aionui_session::SubagentKind::Workflow, + input: serde_json::Value::Null, + parent_tool_use_id: None, + }), + // Workflow starts running (in-flight). + env(SessionEvent::SubagentUpdate { + r#ref: "task-1".into(), + label: Some("wf".into()), + status: SubagentStatus::Running, + parent_ref: Some("toolu_wf".into()), + }), + env(SessionEvent::MessageDelta { + item_id: "m".into(), + text: "launching workflow".into(), + }), + // LAUNCH result — arrives while the workflow is still in flight. Its + // Finish MUST be suppressed. + env(SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: String::new(), + epoch: 0, + outcome: aionui_session::TurnOutcome::EndTurn, + }), + // Workflow completes (matches the fixture invariant: completed precedes + // the terminal result). + env(SessionEvent::SubagentUpdate { + r#ref: "task-1".into(), + label: Some("wf".into()), + status: SubagentStatus::Completed, + parent_ref: Some("toolu_wf".into()), + }), + env(SessionEvent::MessageDelta { + item_id: "m2".into(), + text: "workflow done".into(), + }), + // TERMINAL result — workflow drained, so this Finish is forwarded. + env(SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: String::new(), + epoch: 0, + outcome: aionui_session::TurnOutcome::EndTurn, + }), + ]; + let frames = drain_script(script).await; + let seq: Vec<&str> = frames.iter().map(frame_name).collect(); + // Exactly ONE finish, and BOTH text segments (launch reply + completion + // message) reach the frontend before it. + let finish_count = frames + .iter() + .filter(|f| matches!(f, AgentStreamEvent::Finish(_))) + .count(); + assert_eq!( + finish_count, 1, + "only the terminal result's Finish is forwarded, got {seq:?}" + ); + let text_count = frames.iter().filter(|f| matches!(f, AgentStreamEvent::Text(_))).count(); + assert_eq!( + text_count, 2, + "both the launch reply and the workflow completion text survive, got {seq:?}" + ); + // The single Finish is LAST — the completion text precedes it. + assert!( + matches!(frames.last(), Some(AgentStreamEvent::Finish(_))), + "the terminal Finish comes after the workflow completion message, got {seq:?}" + ); + // The suppressed launch result emits exactly one SegmentBreak so the relay + // closes the launch text segment and the completion reply renders as a + // separate bubble instead of being concatenated under one msg_id. + let break_count = frames + .iter() + .filter(|f| matches!(f, AgentStreamEvent::SegmentBreak)) + .count(); + assert_eq!( + break_count, 1, + "the suppressed launch result emits one SegmentBreak, got {seq:?}" + ); + // The SegmentBreak sits BETWEEN the launch text and the completion text. + // (frame_name maps Text -> "content".) + let first_text = seq.iter().position(|k| *k == "content").unwrap(); + let seg_break = seq.iter().position(|k| *k == "SegmentBreak").unwrap(); + let last_text = seq.iter().rposition(|k| *k == "content").unwrap(); + assert!( + first_text < seg_break && seg_break < last_text, + "SegmentBreak must separate the two text batches, got {seq:?}" + ); + } + + // A workflow-launch result that is itself an ERROR is NOT suppressed — the user + // must see a genuine failure even mid-workflow (suppression covers only clean + // completion ordering, per the fixture invariant). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn errored_result_is_not_suppressed_even_with_inflight_workflow() { + use aionui_session::SubagentStatus; + let script = vec![ + env(SessionEvent::SubagentUpdate { + r#ref: "task-1".into(), + label: Some("wf".into()), + status: SubagentStatus::Running, + parent_ref: None, + }), + env(SessionEvent::TurnResult { + is_error: true, + api_error_status: None, + result_text: "provider exploded".into(), + epoch: 0, + outcome: aionui_session::TurnOutcome::EndTurn, + }), + ]; + let frames = drain_script(script).await; + assert!( + frames.iter().any(|f| matches!(f, AgentStreamEvent::Error(_))), + "an error result terminates the turn even while a workflow is in flight, got {:?}", + frames.iter().map(frame_name).collect::>() + ); + } + + /// Backend that reports one scripted pending permission — models a permission + /// raised before the client subscribed, which the REST /confirmations recovery + /// path must be able to rebuild. + struct PendingPermBackend(aionui_session::PendingPermissionView); + + #[async_trait::async_trait] + impl SessionBackend for PendingPermBackend { + async fn dispatch(&self, _c: Command) -> Result { + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: 0, + }) + } + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + use futures_util::StreamExt as _; + futures_util::stream::empty().boxed() + } + fn capabilities(&self) -> Capabilities { + Capabilities::default() + } + fn pending_permission_requests(&self) -> Vec { + vec![self.0.clone()] + } + } + + // get_confirmations must recover a pending AskUserQuestion as a question card + // (its options), not an empty/allow-deny card — else a page refresh loses the + // question and the turn hangs. (Regression guard: get_confirmations used to + // return an empty Vec unconditionally.) + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn get_confirmations_recovers_pending_ask_user_question() { + let backend: Arc = Arc::new(PendingPermBackend(aionui_session::PendingPermissionView { + request_id: "req-recover".into(), + tool_name: "AskUserQuestion".into(), + questions: Some(serde_json::json!({ + "questions": [{ + "question": "Which?", + "options": [{"label": "A"}, {"label": "B"}] + }] + })), + })); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let confs = task.get_confirmations(); + assert_eq!(confs.len(), 1, "the pending permission must be recovered"); + assert_eq!( + confs[0].call_id, "req-recover", + "card id == request_id for live/recovered de-dup" + ); + let labels: Vec<&str> = confs[0].options.iter().map(|o| o.label.as_str()).collect(); + assert_eq!( + labels, + vec!["A", "B"], + "recovered as the question's options, not allow/deny" + ); + } + + // A pending ordinary tool permission recovers as the generic allow/deny card. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn get_confirmations_recovers_generic_permission() { + let backend: Arc = Arc::new(PendingPermBackend(aionui_session::PendingPermissionView { + request_id: "req-tool".into(), + tool_name: "Bash".into(), + questions: None, + })); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let confs = task.get_confirmations(); + assert_eq!(confs.len(), 1); + let vals: Vec = confs[0] + .options + .iter() + .filter_map(|o| o.value.as_str().map(str::to_owned)) + .collect(); + assert_eq!(vals, vec!["allow", "allow_always", "reject"]); + } + + /// Backend whose capabilities advertise modes+models but whose current_* never + /// changes — models the claude constraint that an in-band switch is NOT reflected + /// in capabilities(). Proves set_config_option's optimistic override makes the + /// response satisfy the frontend's Observed contract regardless. + pub(super) struct StaticCapsBackend; + + #[async_trait::async_trait] + impl SessionBackend for StaticCapsBackend { + async fn dispatch(&self, _c: Command) -> Result { + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: 0, + }) + } + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + use futures_util::StreamExt as _; + futures_util::stream::empty().boxed() + } + fn capabilities(&self) -> Capabilities { + use aionui_session::{ModeInfo, ModelInfo}; + Capabilities { + available_modes: vec![ + ModeInfo { + id: "default".into(), + name: "Default".into(), + description: None, + }, + ModeInfo { + id: "plan".into(), + name: "Plan".into(), + description: None, + }, + ], + current_mode: Some("default".into()), + available_models: vec![ + ModelInfo { + id: "opus".into(), + name: "Opus".into(), + description: None, + reasoning_efforts: vec![], + }, + ModelInfo { + id: "sonnet".into(), + name: "Sonnet".into(), + description: None, + reasoning_efforts: vec![], + }, + ], + current_model: Some("opus".into()), + ..Default::default() + } + } + } + + // set_config_option("mode") must return Observed with the requested value even + // though capabilities().current_mode never moves — the optimistic override drives + // the observed re-read. (Regression guard for the "switching mode → command_ack" + // error the origin frontend rejects.) + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn set_config_option_mode_returns_observed_via_override() { + let backend: Arc = Arc::new(StaticCapsBackend); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let resp = task.set_config_option("mode", "plan").await.unwrap(); + assert!( + matches!(resp.confirmation, aionui_api_types::ConfigOptionConfirmation::Observed), + "mode switch must be Observed, got {:?}", + resp.confirmation + ); + let opts = resp.config_options.expect("config_options present"); + let mode_opt = opts.iter().find(|o| o.id == "mode").expect("mode option"); + assert_eq!( + mode_opt.current_value.as_deref(), + Some("plan"), + "current_value reflects the switch" + ); + } + + // Same for model — critical because claude gives set_model NO confirmation wire, + // so ONLY the override can make it read back as observed. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn set_config_option_model_returns_observed_via_override() { + let backend: Arc = Arc::new(StaticCapsBackend); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let resp = task.set_config_option("model", "sonnet").await.unwrap(); + assert!( + matches!(resp.confirmation, aionui_api_types::ConfigOptionConfirmation::Observed), + "model switch must be Observed, got {:?}", + resp.confirmation + ); + // And get_model reflects the override too (picker highlight follows). + let m = task.get_model().await.unwrap().model_info.expect("model_info"); + assert_eq!(m.current_model_id.as_deref(), Some("sonnet")); + } + + // #3: a runtime switch to a value NOT in the advertised catalog is REJECTED + // (bad_request), not silently dropped and not dispatched — the user's chosen + // reject-and-report behavior. Non-empty catalog that omits the value → reject. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn set_config_option_rejects_invalid_mode_and_model() { + let backend: Arc = Arc::new(StaticCapsBackend); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + + let mode_err = task + .set_config_option("mode", "no-such-mode") + .await + .expect_err("a mode outside the catalog must be rejected"); + assert!( + matches!(mode_err, AgentError::BadRequest(_)), + "invalid mode → BadRequest, got {mode_err:?}" + ); + + let model_err = task + .set_config_option("model", "no-such-model") + .await + .expect_err("a model outside the catalog must be rejected"); + assert!( + matches!(model_err, AgentError::BadRequest(_)), + "invalid model → BadRequest, got {model_err:?}" + ); + + // The optimistic overrides must NOT have moved (nothing was dispatched). + assert!( + task.runtime.mode_override().is_none() && task.runtime.model_override().is_none(), + "a rejected switch must not set an optimistic override" + ); + } + + // codex ToolOutputDelta (streamed command stdout) must surface as tool_call + // frames carrying the CUMULATIVE output (the frontend REPLACES output on merge, + // so sending raw deltas would show only the last chunk). Each frame keys on the + // item_id so the frontend appends to the right tool. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn tool_output_delta_accumulates_cumulative_output() { + let script = vec![ + env(SessionEvent::ToolOutputDelta { + item_id: "call_0".into(), + text: "line-1\n".into(), + }), + env(SessionEvent::ToolOutputDelta { + item_id: "call_0".into(), + text: "line-2\n".into(), + }), + ]; + let frames = drain_script(script).await; + let outputs: Vec = frames + .iter() + .filter_map(|f| match f { + AgentStreamEvent::ToolCall(d) if d.call_id == "call_0" => d.output.clone(), + _ => None, + }) + .collect(); + // Two frames: first the 1st chunk, then the cumulative 1st+2nd (not just "line-2"). + assert_eq!(outputs, vec!["line-1\n".to_string(), "line-1\nline-2\n".to_string()]); + } + + // ── Defect 1: process-reap on task drop ─────────────────────────────── + // Faithfully models `ClaudeSessionBackend`: `events()` subscribes to a + // broadcast `Sender` the backend struct OWNS, so the event stream stays open + // (pending, never Closed) exactly as long as the backend is alive — and reaping + // the child CLI happens in the backend's `Drop`. If `spawn_event_pump` captured a + // backend `Arc`, that Arc would keep `event_tx` alive after the task Arc is + // dropped, so the stream would never Close, the pump loop would never exit, the + // backend would never Drop, and the child CLI would leak. This test proves the + // pump holds ONLY the stream: dropping the sole task Arc must fire the backend's + // Drop (i.e. reap) promptly. + struct ReapBackend { + // Owning this sender keeps `events()` subscribers pending while the backend + // lives, mirroring the real backend's `event_tx` field. + event_tx: broadcast::Sender, + // Fired from `Drop` — stands in for "the child process was reaped". + reap_signal: std::sync::Mutex>>, + } + + #[async_trait::async_trait] + impl SessionBackend for ReapBackend { + async fn dispatch(&self, _c: Command) -> Result { + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: 0, + }) + } + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + use futures_util::StreamExt as _; + // Subscribe here (like ClaudeSessionBackend::events): the returned stream + // captures ONLY the Receiver, never `self`. It yields nothing and only + // ends when every Sender — i.e. the field below — is dropped. + let rx = self.event_tx.subscribe(); + futures_util::stream::unfold(rx, |mut rx| async move { + match rx.recv().await { + Ok(env) => Some((env, rx)), + Err(_) => None, + } + }) + .boxed() + } + fn capabilities(&self) -> Capabilities { + Capabilities::default() + } + } + + impl Drop for ReapBackend { + fn drop(&mut self) { + if let Some(tx) = self.reap_signal.lock().unwrap().take() { + let _ = tx.send(()); + } + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn dropping_task_reaps_backend() { + let (reaped_tx, reaped_rx) = tokio::sync::oneshot::channel(); + let (event_tx, _keep) = broadcast::channel(8); + let backend: Arc = Arc::new(ReapBackend { + event_tx, + reap_signal: std::sync::Mutex::new(Some(reaped_tx)), + }); + // `_keep` is dropped here, so the ONLY remaining Sender is the backend's field + // — the reap now hinges purely on the backend being dropped. + drop(_keep); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + // Let the pump subscribe and settle into its await. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // Drop the sole strong task Arc. Post-fix, this drops `task.backend` (the only + // long-lived backend Arc) → ReapBackend::drop fires. Pre-fix, the pump held a + // backend Arc and this would hang. + drop(task); + + tokio::time::timeout(std::time::Duration::from_secs(2), reaped_rx) + .await + .expect("backend must be dropped (reaped) promptly after the task Arc is dropped") + .expect("reap signal delivered"); + } + + // Build a persisted-handshake value in the SHAPE `spawn_catalog_writeback` stores + // (the top-level `{available_models:[{id,label}], current_model_id}` column shape). + fn handshake_with_catalog() -> aionui_api_types::AgentHandshake { + aionui_api_types::AgentHandshake { + available_models: Some(serde_json::json!({ + "available_models": [ + {"id": "opus", "label": "Opus"}, + {"id": "sonnet", "label": "Sonnet"}, + ], + "current_model_id": "sonnet", + })), + available_modes: Some(serde_json::json!({ + "available_modes": [ + {"id": "default", "name": "Default"}, + {"id": "plan", "name": "Plan"}, + ], + "current_mode_id": "plan", + })), + ..Default::default() + } + } + + // Cold-start resume: the backend's live capabilities() is still empty (initialize + // round-trip not landed), but the persisted-handshake preload populates the picker + // so it is NOT blank. This is the fix for "session-port history-open shows an empty + // model list for ~seconds while ACP's persisted preload keeps it filled". + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn preload_serves_catalog_when_live_capabilities_empty() { + // ScriptBackend has empty capabilities() → live catalog is absent. + let backend: Arc = Arc::new(ScriptBackend(Vec::new())); + let task = SessionAgentTask::new_with_preload( + AgentType::Acp, + "conv-1".into(), + "/w".into(), + backend, + None, + &handshake_with_catalog(), + ); + + // get_model serves the preloaded catalog + persisted current model. + let m = task + .get_model() + .await + .unwrap() + .model_info + .expect("model_info from preload"); + assert_eq!( + m.available_models.iter().map(|e| e.id.as_str()).collect::>(), + vec!["opus", "sonnet"], + ); + assert_eq!(m.current_model_id.as_deref(), Some("sonnet")); + + // get_config_options renders both mode+model selects from the preload. + let opts = task.get_config_options().await.unwrap().config_options; + let model_opt = opts + .iter() + .find(|o| o.id == "model") + .expect("model select from preload"); + assert_eq!( + model_opt.options.iter().map(|o| o.value.as_str()).collect::>(), + vec!["opus", "sonnet"], + ); + assert_eq!(model_opt.current_value.as_deref(), Some("sonnet")); + let mode_opt = opts.iter().find(|o| o.id == "mode").expect("mode select from preload"); + assert_eq!(mode_opt.current_value.as_deref(), Some("plan")); + + // mode() serves the preloaded current mode. + assert_eq!(task.mode().await.unwrap().mode, "plan"); + } + + // The live catalog OVERWRITES the preload the instant it is present: even though a + // (stale) preload is supplied, a backend with non-empty capabilities() serves the + // live values — matching ACP's "fill-when-empty, live-overwrites" semantics and + // preventing a stale persisted catalog from masking a fresh engine's model list. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn live_capabilities_overwrite_stale_preload() { + use super::pump_tests::StaticCapsBackend; + // Preload advertises a codex-shaped catalog; live StaticCapsBackend advertises + // opus/sonnet with current=opus. Live must win on every axis. + let stale = aionui_api_types::AgentHandshake { + available_models: Some(serde_json::json!({ + "available_models": [{"id": "stale-model", "label": "Stale"}], + "current_model_id": "stale-model", + })), + ..Default::default() + }; + let backend: Arc = Arc::new(StaticCapsBackend); + let task = + SessionAgentTask::new_with_preload(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None, &stale); + let m = task.get_model().await.unwrap().model_info.expect("model_info"); + assert_eq!( + m.available_models.iter().map(|e| e.id.as_str()).collect::>(), + vec!["opus", "sonnet"], + "live capabilities must overwrite the stale preload" + ); + assert_eq!(m.current_model_id.as_deref(), Some("opus")); + } + + // Cold-start pre-init: the backend has already seeded `current_model`/`current_mode` + // from the RESOLVED snapshot (claude_conn spawn: `caps.current_model = config.model`, + // config.model = persisted `current_model_id`), but `available_models` is still empty + // (the initialize round-trip has not landed). The persisted-handshake preload's + // current is STALE (frozen at the prior session's write-back, which does not re-run on + // a mid-turn switch). The picker must show the backend's snapshot-seeded current — the + // model claude actually runs — NOT the stale preload current, even though the LIST is + // served from preload in this same window. This is the per-axis-independent fallback. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn caps_current_wins_over_stale_preload_while_list_is_empty() { + // Backend: current seeded (user last switched to opus), but lists still empty. + struct CurrentOnlyBackend; + #[async_trait::async_trait] + impl SessionBackend for CurrentOnlyBackend { + async fn dispatch(&self, _c: Command) -> Result { + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: 0, + }) + } + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + use futures_util::StreamExt as _; + futures_util::stream::empty().boxed() + } + fn capabilities(&self) -> Capabilities { + Capabilities { + current_model: Some("opus".into()), + current_mode: Some("default".into()), + ..Default::default() + } + } + } + + // Preload (prior write-back) still says sonnet/plan — the pre-switch values. + let backend: Arc = Arc::new(CurrentOnlyBackend); + let task = SessionAgentTask::new_with_preload( + AgentType::Acp, + "conv-1".into(), + "/w".into(), + backend, + None, + &handshake_with_catalog(), + ); + + let m = task.get_model().await.unwrap().model_info.expect("model_info"); + // LIST comes from the preload (backend list is empty pre-init)... + assert_eq!( + m.available_models.iter().map(|e| e.id.as_str()).collect::>(), + vec!["opus", "sonnet"], + "list falls back to preload while the backend list is empty" + ); + // ...but the CURRENT is the backend's snapshot-seeded model, NOT the stale preload. + assert_eq!( + m.current_model_id.as_deref(), + Some("opus"), + "current_model must be the backend's snapshot-seeded value, not the stale preload's sonnet" + ); + + let opts = task.get_config_options().await.unwrap().config_options; + let model_opt = opts.iter().find(|o| o.id == "model").expect("model select"); + assert_eq!(model_opt.current_value.as_deref(), Some("opus")); + let mode_opt = opts.iter().find(|o| o.id == "mode").expect("mode select"); + assert_eq!( + mode_opt.current_value.as_deref(), + Some("default"), + "current_mode must be the backend's snapshot-seeded value, not the stale preload's plan" + ); + assert_eq!(task.mode().await.unwrap().mode, "default"); + } +} diff --git a/crates/aionui-ai-agent/tests/acp_agent_integration.rs b/crates/aionui-ai-agent/tests/acp_agent_integration.rs index 286d014da..975622cd6 100644 --- a/crates/aionui-ai-agent/tests/acp_agent_integration.rs +++ b/crates/aionui-ai-agent/tests/acp_agent_integration.rs @@ -193,6 +193,7 @@ fn event_type_name(event: &AgentStreamEvent) -> &'static str { AgentStreamEvent::RequestTrace(_) => "RequestTrace", AgentStreamEvent::SlashCommandsUpdated(_) => "SlashCommandsUpdated", AgentStreamEvent::SessionAssigned(_) => "SessionAssigned", + AgentStreamEvent::SegmentBreak => "SegmentBreak", } } diff --git a/crates/aionui-ai-agent/tests/factory_provider_integration.rs b/crates/aionui-ai-agent/tests/factory_provider_integration.rs index 9792698dd..39d91ec89 100644 --- a/crates/aionui-ai-agent/tests/factory_provider_integration.rs +++ b/crates/aionui-ai-agent/tests/factory_provider_integration.rs @@ -78,6 +78,7 @@ fn make_factory( broadcaster: Arc::new(BroadcastEventBus::new(16)), backend_binary_path: Arc::new(PathBuf::from("/tmp/aionrs-test/aioncore")), mcp_server_repo: None, + session_spawner: None, }) } diff --git a/crates/aionui-app/Cargo.toml b/crates/aionui-app/Cargo.toml index 2f8d755af..5b1b1eb0e 100644 --- a/crates/aionui-app/Cargo.toml +++ b/crates/aionui-app/Cargo.toml @@ -36,10 +36,13 @@ aionui-team-prompts.workspace = true aionui-cron.workspace = true aionui-assistant.workspace = true aionui-runtime.workspace = true +aionui-process.workspace = true +aion-config.workspace = true axum.workspace = true chrono.workspace = true dirs.workspace = true tokio.workspace = true +uuid.workspace = true tower.workspace = true tower-http.workspace = true tracing.workspace = true diff --git a/crates/aionui-app/src/services.rs b/crates/aionui-app/src/services.rs index 39b61ba20..961e5ac6b 100644 --- a/crates/aionui-app/src/services.rs +++ b/crates/aionui-app/src/services.rs @@ -164,6 +164,34 @@ impl AppServices { let runtime_helper_bin = backend_binary_path.to_string_lossy().into_owned(); let runtime_base_url = config.local_base_url(); + // Session-model port (phase 1): the subprocess spawner the clean-slate + // claude/codex SessionBackend uses. Registry-backed (feature 001) so spawned + // processes are reap-gateable; a fresh per-run epoch (no cross-run reap + // authority is required for the port's spawn path). + // + // A/B differential-test escape hatch: setting AIONUI_DISABLE_SESSION_PORT=1 + // leaves the spawner un-wired (None), so claude/codex fall through to the + // legacy AcpAgentManager path in factory/acp.rs. This lets the SAME binary + // drive the SAME claude/codex CLI through EITHER path (flip the env, restart) + // for frame-by-frame equivalence comparison. Unset (production) = session path. + let session_port_disabled = std::env::var("AIONUI_DISABLE_SESSION_PORT") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let session_spawner: Option> = if session_port_disabled { + tracing::warn!( + "AIONUI_DISABLE_SESSION_PORT set — claude/codex will use the legacy AcpAgentManager path (A/B test mode)" + ); + None + } else { + let process_registry = Arc::new(aionui_process::FileRegistryStore::new(&data_dir)); + let machine_id = aionui_process::local_machine_id(&data_dir); + Some(Arc::new(aionui_process::RealSpawner::new( + process_registry, + uuid::Uuid::now_v7(), + machine_id, + ))) + }; + let factory = build_agent_factory(AgentFactoryDeps { skill_manager: AcpSkillManager::new_with_repo(skill_paths.clone(), skill_repo.clone()), provider_repo, @@ -175,6 +203,7 @@ impl AppServices { broadcaster: event_bus.clone(), backend_binary_path: backend_binary_path.clone(), mcp_server_repo: Some(mcp_server_repo), + session_spawner, }); // Agent factory is now wired. Future extension/custom agents diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index 37b627fbf..839cce9db 100644 --- a/crates/aionui-channel/src/message_service.rs +++ b/crates/aionui-channel/src/message_service.rs @@ -231,7 +231,8 @@ impl ChannelMessageService { | AgentStreamEvent::System(_) | AgentStreamEvent::RequestTrace(_) | AgentStreamEvent::SlashCommandsUpdated(_) - | AgentStreamEvent::SessionAssigned(_) => None, + | AgentStreamEvent::SessionAssigned(_) + | AgentStreamEvent::SegmentBreak => None, } } diff --git a/crates/aionui-common/src/error_extract.rs b/crates/aionui-common/src/error_extract.rs new file mode 100644 index 000000000..bc697de81 --- /dev/null +++ b/crates/aionui-common/src/error_extract.rs @@ -0,0 +1,253 @@ +//! Extracts a user-facing error message from a CLI subprocess's recent stderr. +//! +//! ACP / CLI child processes (codex-acp, claude-acp, codex, …) often emit +//! important error context as `tracing` events to stderr **without** including +//! it in the JSON-RPC error response or the result frame we receive. This +//! module turns the last few stderr lines into a single human-readable message, +//! but only if the content matches an allowlisted error keyword — stderr is not +//! a trusted source. +//! +//! Returns `None` whenever no allowlisted line is found; callers must keep +//! their existing error string in that case. +//! +//! This is the shared home for stderr redaction across the clean-slate session +//! backends. It lives in `aionui-common` (below every consumer: `aionui-session`, +//! `aionui-conversation`, the agent-* crates all depend on common) so the +//! consuming backends gain no upward crate dependency. Ported verbatim from the +//! deleted `aionui-ai-agent::manager::acp::stderr_error_extractor` (git +//! `ec27676d^`); the allowlist below is security-load-bearing — see the +//! per-entry notes before editing. + +/// Allowlisted lowercase keywords. A stderr line is considered "user-relevant" +/// only if it contains at least one of these (case-insensitive). +/// +/// Order does not matter for matching, but keep this short and audited — every +/// new entry expands what stderr content can reach end-users. +pub const ERROR_KEYWORDS: &[&str] = &[ + "usage limit", + "rate limit", + "exceeded", + "unauthorized", + "forbidden", + "network", + "timeout", + "connection refused", + "connection reset", + "tls", + "dns", + // HIGH-RISK: may surface token/key values from stderr (e.g. "credentials: + // Bearer eyJ..."). The 240-char cap does not protect a 40-60 char secret. + // Keep matched lines out of operator logs that get exfiltrated. + "credentials", + "api key", + "quota", + "billing", +]; + +const MAX_MESSAGE_CHARS: usize = 240; + +/// Strip ANSI CSI escape sequences (`\u{1b}[...m` and similar) from `s`. +/// +/// Minimal implementation — handles the SGR (`m`) terminator that `tracing`'s +/// ANSI subscriber uses. Other CSI commands (cursor moves etc.) are stripped +/// the same way as long as they end in `[A-Za-z]`. +fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut iter = s.chars().peekable(); + while let Some(c) = iter.next() { + if c == '\u{1b}' && matches!(iter.peek(), Some('[')) { + iter.next(); // consume '[' + for ch in iter.by_ref() { + if ch.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out +} + +/// Extract the user-relevant message tail from a single stripped tracing line. +/// +/// Tracing format roughly: ` {}: `. +/// We split on `": "` and take the last segment — that's almost always the +/// message. Returns the trimmed segment. +fn message_tail(stripped_line: &str) -> &str { + stripped_line + .rsplit_once(": ") + .map(|(_, tail)| tail) + .unwrap_or(stripped_line) + .trim() +} + +fn matches_allowlist(line_lower: &str) -> bool { + ERROR_KEYWORDS.iter().any(|kw| line_lower.contains(kw)) +} + +/// Pick the most informative error line from a chunk of recent stderr. +/// +/// Returns `None` if nothing matches the allowlist — caller should keep its +/// existing error message rather than substitute an empty string. +pub fn extract_error_message(stderr_tail: &str) -> Option { + if stderr_tail.trim().is_empty() { + return None; + } + + let lines: Vec = stderr_tail + .lines() + .map(strip_ansi) + .map(|l| l.trim().to_owned()) + .filter(|l| !l.is_empty()) + .collect(); + + // Pass 1: latest line whose stripped form contains "ERROR" AND matches the allowlist. + let mut chosen: Option<&str> = None; + for line in lines.iter().rev() { + let lower = line.to_lowercase(); + if lower.contains("error") && matches_allowlist(&lower) { + chosen = Some(line.as_str()); + break; + } + } + // Pass 2: latest line that matches the allowlist regardless of level (e.g. WARN). + if chosen.is_none() { + for line in lines.iter().rev() { + let lower = line.to_lowercase(); + if matches_allowlist(&lower) { + chosen = Some(line.as_str()); + break; + } + } + } + + let line = chosen?; + let tail = message_tail(line); + let truncated: String = if tail.chars().count() > MAX_MESSAGE_CHARS { + let mut buf: String = tail.chars().take(MAX_MESSAGE_CHARS - 1).collect(); + buf.push('…'); + buf + } else { + tail.to_owned() + }; + Some(truncated) +} + +#[cfg(test)] +mod tests { + use super::extract_error_message; + + const STDERR_USAGE_LIMIT: &str = "\u{1b}[2m2026-05-13T20:01:21.330370Z\u{1b}[0m \u{1b}[31mERROR\u{1b}[0m \u{1b}[2mcodex_acp::thread\u{1b}[0m\u{1b}[2m:\u{1b}[0m Unhandled error during turn: You've hit your usage limit. To get more access now, send a request to your admin or try again at May 14th, 2026 8:16 PM. Some(UsageLimitExceeded)"; + + #[test] + fn extracts_usage_limit_message() { + let result = extract_error_message(STDERR_USAGE_LIMIT); + let msg = result.expect("usage limit must match"); + assert!(msg.contains("usage limit"), "got {msg}"); + assert!(!msg.contains("\u{1b}["), "ANSI escapes must be stripped; got {msg:?}"); + assert!( + !msg.contains("ERROR"), + "tracing level prefix should not leak into user message; got {msg}" + ); + } + + #[test] + fn returns_none_for_unrelated_stderr() { + // No allowlisted keywords → return None, do not pretend. + let stderr = "ERROR widget_loader: failed to load widget xyz"; + assert!(extract_error_message(stderr).is_none()); + } + + #[test] + fn returns_none_for_empty_input() { + assert!(extract_error_message("").is_none()); + assert!(extract_error_message(" \n\n ").is_none()); + } + + #[test] + fn truncates_overlong_message_to_240_chars() { + let mut long = String::from("ERROR upstream: usage limit exceeded "); + long.push_str(&"x".repeat(500)); + let result = extract_error_message(&long).expect("matched on usage limit"); + assert!( + result.chars().count() <= 240, + "result must be ≤240 chars; got {} chars", + result.chars().count() + ); + } + + #[test] + fn prefers_error_over_warn_lines() { + let stderr = "WARN widget: usage limit warning happens\n\ + ERROR upstream: network connection refused\n\ + WARN cleanup: usage limit cleanup ran"; + let result = extract_error_message(stderr).expect("ERROR line must match"); + assert!( + result.contains("network connection refused"), + "ERROR line should win over WARN; got {result}" + ); + } + + #[test] + fn falls_back_to_warn_when_no_matching_error() { + let stderr = "ERROR widget: something unrelated happened\n\ + WARN upstream: rate limit exceeded for token xyz"; + let result = extract_error_message(stderr).expect("WARN match must surface"); + assert!(result.contains("rate limit"), "got {result}"); + } + + #[test] + fn picks_latest_matching_line_when_multiple() { + let stderr = "ERROR upstream: connection timeout 1\n\ + ERROR upstream: connection timeout 2 (latest)"; + let result = extract_error_message(stderr).expect("must match"); + assert!( + result.contains("(latest)"), + "must pick the most recent matching line; got {result}" + ); + } + + #[test] + fn strips_ansi_then_keeps_only_message_after_last_colon() { + // tracing format: " : : " + // We want just the message tail. + let stderr = + "\u{1b}[2m2026-05-13T20:01:21Z\u{1b}[0m \u{1b}[31mERROR\u{1b}[0m foo::bar: ctx=abc: usage limit exceeded"; + let result = extract_error_message(stderr).expect("must match"); + assert_eq!(result, "usage limit exceeded"); + } + + #[test] + fn handles_line_without_colon_separator() { + // Some logs aren't tracing-formatted; we should still surface the line + // when it matches the allowlist, instead of silently mangling it. + let stderr = "usage limit exceeded no colon here"; + let result = extract_error_message(stderr).expect("should match"); + assert_eq!(result, "usage limit exceeded no colon here"); + } + + /// Security regression: a stderr chunk that mixes a secret-bearing line with + /// an allowlisted line must surface ONLY the allowlisted line, never the + /// token. The non-allowlisted secret line (`ANTHROPIC_API_KEY=sk-...`) does + /// not contain any keyword, so it can never be chosen — and the chosen line + /// must not carry the secret either. + #[test] + fn never_surfaces_secret_from_non_allowlisted_line() { + let stderr = "DEBUG bootstrap: loaded ANTHROPIC_API_KEY=sk-ant-0123456789abcdef secret token\n\ + ERROR upstream: rate limit exceeded, retry later"; + let result = extract_error_message(stderr).expect("the rate-limit line must surface"); + assert!( + result.contains("rate limit"), + "expected the allowlisted line; got {result}" + ); + assert!( + !result.contains("sk-ant"), + "secret token must never leak into the user message; got {result}" + ); + assert!( + !result.to_lowercase().contains("anthropic_api_key"), + "env-var name carrying a secret must not leak; got {result}" + ); + } +} diff --git a/crates/aionui-common/src/lib.rs b/crates/aionui-common/src/lib.rs index 03e10a4a9..f80d3619a 100644 --- a/crates/aionui-common/src/lib.rs +++ b/crates/aionui-common/src/lib.rs @@ -7,6 +7,7 @@ mod case_convert; mod crypto; mod enums; mod error; +pub mod error_extract; mod hooks; mod id; mod pagination; diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 8d5181025..c7e11e4eb 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -949,6 +949,10 @@ impl IAcpSessionRepository for StubAcpSessionRepo { *self.session_id.lock().unwrap() = Some(session_id.to_owned()); Ok(true) } + async fn clear_session_id(&self, _conversation_id: &str) -> Result { + *self.session_id.lock().unwrap() = None; + Ok(true) + } async fn delete(&self, _conversation_id: &str) -> Result { Ok(false) } diff --git a/crates/aionui-conversation/src/stream_persistence.rs b/crates/aionui-conversation/src/stream_persistence.rs index 7bcad7a9b..dfb381729 100644 --- a/crates/aionui-conversation/src/stream_persistence.rs +++ b/crates/aionui-conversation/src/stream_persistence.rs @@ -397,6 +397,9 @@ impl StreamPersistenceAdapter { ToolCallStatus::Running => "work", ToolCallStatus::Completed => "finish", ToolCallStatus::Error => "error", + // A cancelled call is terminal: the row must leave "work" so the + // frontend spinner (hasRunningToolMessages) stops after interrupt. + ToolCallStatus::Canceled => "finish", }; let content = serde_json::to_string(data).unwrap_or_default(); @@ -472,9 +475,12 @@ impl StreamPersistenceAdapter { if !self.allows_write(RuntimeWriteKind::ToolGroupPersist) { return; } - let all_done = entries - .iter() - .all(|e| matches!(e.status, ToolCallStatus::Completed | ToolCallStatus::Error)); + let all_done = entries.iter().all(|e| { + matches!( + e.status, + ToolCallStatus::Completed | ToolCallStatus::Error | ToolCallStatus::Canceled + ) + }); let status = if all_done { "finish" } else { "work" }; let content = serde_json::to_string(entries).unwrap_or_default(); diff --git a/crates/aionui-conversation/src/stream_relay.rs b/crates/aionui-conversation/src/stream_relay.rs index a938e7c0b..e18e22754 100644 --- a/crates/aionui-conversation/src/stream_relay.rs +++ b/crates/aionui-conversation/src/stream_relay.rs @@ -289,6 +289,15 @@ impl StreamRelay { } match &event { + AgentStreamEvent::SegmentBreak => { + // Intra-turn soft boundary (see AgentStreamEvent::SegmentBreak): + // close the current text/thinking segment so the next batch of + // text starts a fresh bubble, but do NOT terminate the relay and + // do NOT forward this event to the WebSocket. + self.complete_active_thinking(&mut active_thinking).await; + self.close_active_text_segment(&mut active_text, &mut text_segments, "finish") + .await; + } AgentStreamEvent::Thinking(data) => { if data.status.as_deref() == Some("done") { self.complete_active_thinking(&mut active_thinking).await; @@ -587,6 +596,7 @@ impl StreamRelay { AgentStreamEvent::System(_) => "System", AgentStreamEvent::RequestTrace(_) => "RequestTrace", AgentStreamEvent::SessionAssigned(_) => "SessionAssigned", + AgentStreamEvent::SegmentBreak => "SegmentBreak", } } @@ -986,6 +996,72 @@ mod tests { assert_ne!(text_event_msg_ids[0], text_event_msg_ids[1]); } + // A SegmentBreak (emitted by the direct-CLI pump when it suppresses a + // non-blocking Workflow's launch result) must split text into two bubbles + // just like a tool call does — but WITHOUT terminating the relay and + // WITHOUT being forwarded to the WebSocket as its own frame. + #[tokio::test] + async fn segment_break_splits_text_without_forwarding_or_terminating() { + let repo = Arc::new(RecordingRepo::new()); + let bus = Arc::new(aionui_realtime::BroadcastEventBus::new(64)); + let (tx, _) = broadcast::channel(64); + + let relay = StreamRelay::new( + "conv-1".into(), + "asst-1".into(), + "turn-1".into(), + "user-1".into(), + repo.clone(), + bus.clone(), + ); + + let mut ws_rx = bus.subscribe(); + let rx = tx.subscribe(); + + // launch reply -> SegmentBreak -> completion reply -> Finish + tx.send(AgentStreamEvent::Text(TextEventData { + content: "launching workflow".into(), + })) + .unwrap(); + tx.send(AgentStreamEvent::SegmentBreak).unwrap(); + tx.send(AgentStreamEvent::Text(TextEventData { + content: "workflow done".into(), + })) + .unwrap(); + tx.send(AgentStreamEvent::Finish(FinishEventData::default())).unwrap(); + + relay.consume(rx).await; + + // Two persisted text segments under two different msg_ids. + let inserts = repo.take_inserts(); + let text_msgs: Vec<_> = inserts.iter().filter(|msg| msg.r#type == "text").collect(); + assert_eq!(text_msgs.len(), 2, "SegmentBreak should split text into two segments"); + assert_eq!(text_msgs[0].id, "asst-1"); + assert_ne!(text_msgs[0].id, text_msgs[1].id); + + // The two text frames reach the WS under two msg_ids, and no + // `segment_break` frame is ever forwarded. + let mut text_event_msg_ids = Vec::new(); + let mut saw_segment_break_frame = false; + while let Ok(evt) = ws_rx.try_recv() { + if evt.name == "message.stream" { + if evt.data["type"] == "text" || evt.data["type"] == "content" { + text_event_msg_ids.push(evt.data["msg_id"].as_str().unwrap_or_default().to_owned()); + } + if evt.data["type"] == "segment_break" { + saw_segment_break_frame = true; + } + } + } + assert!( + !saw_segment_break_frame, + "SegmentBreak must never be forwarded to the WS" + ); + assert_eq!(text_event_msg_ids.len(), 2); + assert_eq!(text_event_msg_ids[0], "asst-1"); + assert_ne!(text_event_msg_ids[0], text_event_msg_ids[1]); + } + #[tokio::test] async fn run_error_with_no_text_stores_tips_message() { let repo = Arc::new(RecordingRepo::new()); diff --git a/crates/aionui-conversation/tests/stream_relay_tool_call.rs b/crates/aionui-conversation/tests/stream_relay_tool_call.rs index 834a62dd2..57da66b4d 100644 --- a/crates/aionui-conversation/tests/stream_relay_tool_call.rs +++ b/crates/aionui-conversation/tests/stream_relay_tool_call.rs @@ -179,6 +179,71 @@ async fn run_tool_call_late_running_event_does_not_regress_completed_message() { assert_eq!(content["description"], "search files"); } +#[tokio::test] +async fn run_tool_call_canceled_status_persists_as_terminal_finish() { + let (repo, _db) = setup_repo().await; + let bus = Arc::new(BroadcastEventBus::new(64)); + let (tx, _) = broadcast::channel(64); + + let relay = StreamRelay::new( + "conv-1".into(), + "asst-1".into(), + "turn-1".into(), + "system_default_user".into(), + repo.clone(), + bus, + ); + + let rx = tx.subscribe(); + tx.send(AgentStreamEvent::ToolCall(ToolCallEventData { + call_id: "bash-1".into(), + name: "Bash".into(), + args: json!({"command": "sleep 60"}), + status: ToolCallStatus::Running, + input: Some(json!({"command": "sleep 60"})), + output: None, + description: None, + })) + .unwrap(); + // The turn was interrupted: the fold layer closes the still-open call as + // Canceled instead of a Completed/Error ToolResult ever arriving. + tx.send(AgentStreamEvent::ToolCall(ToolCallEventData { + call_id: "bash-1".into(), + name: "Bash".into(), + args: serde_json::Value::Null, + status: ToolCallStatus::Canceled, + input: None, + output: None, + description: None, + })) + .unwrap(); + tx.send(AgentStreamEvent::Finish(FinishEventData::default())).unwrap(); + + relay.consume(rx).await; + + let messages = repo + .list_messages_page( + "conv-1", + &MessagePageParams { + limit: 100, + direction: MessagePageDirection::InitialLatest, + }, + ) + .await + .unwrap(); + let msg = messages + .items + .iter() + .find(|row| row.id == "bash-1" && row.r#type == "tool_call") + .expect("tool call row should be persisted"); + // Terminal row status must leave "work" so the frontend spinner + // (hasRunningToolMessages) stops after an interrupt. + assert_eq!(msg.status.as_deref(), Some("finish")); + + let content: serde_json::Value = serde_json::from_str(&msg.content).unwrap(); + assert_eq!(content["status"], "canceled"); +} + struct ToolCallAgent { conversation_id: String, event_tx: broadcast::Sender, diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index f899ea43d..5dfc92d60 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -3221,6 +3221,9 @@ mod tests { ) -> Result { Ok(false) } + async fn clear_session_id(&self, _conversation_id: &str) -> Result { + Ok(false) + } async fn delete(&self, _conversation_id: &str) -> Result { Ok(false) } diff --git a/crates/aionui-db/src/repository/acp_session.rs b/crates/aionui-db/src/repository/acp_session.rs index ccf40e24d..e0169dab8 100644 --- a/crates/aionui-db/src/repository/acp_session.rs +++ b/crates/aionui-db/src/repository/acp_session.rs @@ -78,6 +78,16 @@ pub trait IAcpSessionRepository: Send + Sync { /// `session/load` succeeds. Returns `true` when the row existed. async fn update_session_id(&self, conversation_id: &str, session_id: &str) -> Result; + /// Null the stored `session_id`, dropping the resume anchor while keeping the + /// row (config/runtime state) intact. Called on an unrecoverable resume error + /// ("No conversation found" / `error_during_execution`) so the NEXT turn opens + /// Fresh instead of re-resuming a dead backend session forever. Distinct from + /// [`delete`](Self::delete), which drops the whole row. Returns `true` when the + /// row existed. This is the direct-CLI equivalent of the clean-slate + /// `Orchestrator` emitting `BackendBound{None}` and the legacy ACP + /// `rebuild_after_session_not_found` → `clear_session_id` self-heal. + async fn clear_session_id(&self, conversation_id: &str) -> Result; + /// Delete the row. Called by the conversation delete hook — no DB /// foreign key, so this must be invoked explicitly. async fn delete(&self, conversation_id: &str) -> Result; diff --git a/crates/aionui-db/src/repository/sqlite_acp_session.rs b/crates/aionui-db/src/repository/sqlite_acp_session.rs index 7c7980be8..f373ea2a8 100644 --- a/crates/aionui-db/src/repository/sqlite_acp_session.rs +++ b/crates/aionui-db/src/repository/sqlite_acp_session.rs @@ -76,6 +76,17 @@ impl IAcpSessionRepository for SqliteAcpSessionRepository { Ok(result.rows_affected() > 0) } + async fn clear_session_id(&self, conversation_id: &str) -> Result { + let now = now_ms(); + let result = + sqlx::query("UPDATE acp_session SET session_id = NULL, last_active_at = ? WHERE conversation_id = ?") + .bind(now) + .bind(conversation_id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } + async fn delete(&self, conversation_id: &str) -> Result { let result = sqlx::query("DELETE FROM acp_session WHERE conversation_id = ?") .bind(conversation_id) @@ -263,6 +274,27 @@ mod tests { assert!(!repo.update_session_id("nope", "sid").await.unwrap()); } + #[tokio::test] + async fn clear_session_id_nulls_field_but_keeps_row() { + let (repo, _db) = setup().await; + repo.create(&create_params("conv-1")).await.unwrap(); + repo.update_session_id("conv-1", "sess-abc").await.unwrap(); + + assert!(repo.clear_session_id("conv-1").await.unwrap()); + let fetched = repo.get("conv-1").await.unwrap().unwrap(); + assert_eq!(fetched.session_id, None, "the resume anchor must be nulled"); + assert_eq!( + fetched.session_status, "idle", + "the row (and its config) survives the clear" + ); + } + + #[tokio::test] + async fn clear_session_id_missing_row_returns_false() { + let (repo, _db) = setup().await; + assert!(!repo.clear_session_id("nope").await.unwrap()); + } + #[tokio::test] async fn delete_removes_row() { let (repo, _db) = setup().await; diff --git a/crates/aionui-db/src/repository/sqlite_conversation.rs b/crates/aionui-db/src/repository/sqlite_conversation.rs index c27aae2a0..fdc9acfda 100644 --- a/crates/aionui-db/src/repository/sqlite_conversation.rs +++ b/crates/aionui-db/src/repository/sqlite_conversation.rs @@ -12,6 +12,27 @@ use crate::repository::conversation::{ MessagePageParams, MessagePageResult, MessageRowUpdate, MessageSearchRow, }; +/// Bump `conversations.updated_at` so the conversation-list sort +/// (ORDER BY conversations.updated_at DESC) floats a conversation with fresh +/// activity to the top. Persisting a message never used to touch this column, +/// so a conversation receiving new messages stayed frozen at its last +/// create/rename/reset time. `MAX(updated_at, ?)` keeps recency monotonic: an +/// out-of-order streaming upsert (older event time) can never move a +/// conversation backward in the list. Runs inside the caller's transaction so +/// the message write and the bump commit atomically. +async fn bump_conversation_updated_at( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + conversation_id: &str, + at: i64, +) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE conversations SET updated_at = MAX(updated_at, ?) WHERE id = ?") + .bind(at) + .bind(conversation_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + /// SQLite-backed implementation of [`IConversationRepository`]. #[derive(Clone, Debug)] pub struct SqliteConversationRepository { @@ -24,6 +45,7 @@ impl SqliteConversationRepository { } async fn insert_message_once(&self, message: &MessageRow) -> Result<(), sqlx::Error> { + let mut tx = self.pool.begin().await?; sqlx::query( "INSERT INTO messages \ (id, conversation_id, msg_id, type, content, position, \ @@ -39,13 +61,17 @@ impl SqliteConversationRepository { .bind(&message.status) .bind(message.hidden) .bind(message.created_at) - .execute(&self.pool) + .execute(&mut *tx) .await?; + bump_conversation_updated_at(&mut tx, &message.conversation_id, message.created_at).await?; + tx.commit().await?; + Ok(()) } async fn upsert_message_once(&self, message: &MessageRow) -> Result<(), sqlx::Error> { + let mut tx = self.pool.begin().await?; sqlx::query( "INSERT INTO messages \ (id, conversation_id, msg_id, type, content, position, \ @@ -85,9 +111,12 @@ impl SqliteConversationRepository { .bind(&message.status) .bind(message.hidden) .bind(message.created_at) - .execute(&self.pool) + .execute(&mut *tx) .await?; + bump_conversation_updated_at(&mut tx, &message.conversation_id, message.created_at).await?; + tx.commit().await?; + Ok(()) } @@ -1093,6 +1122,50 @@ mod tests { assert!(repo.get("no_such_id").await.unwrap().is_none()); } + /// Persisting a message must bump the parent conversation's updated_at so the + /// conversation-list sort (ORDER BY conversations.updated_at DESC) floats a + /// conversation with fresh activity to the top. Recency is monotonic — an + /// out-of-order (older) upsert must not move it backward. + #[tokio::test] + async fn insert_message_bumps_conversation_updated_at() { + let (repo, _db) = setup().await; + let mut conv = sample_conversation(SYSTEM_USER_ID); + conv.updated_at = 1; // force a known-stale baseline + repo.create(&conv).await.unwrap(); + + // insert_message with a newer event time bumps updated_at forward. + let mut msg = sample_message(&conv.id); + msg.created_at = 5_000; + repo.insert_message(&msg).await.unwrap(); + assert_eq!( + repo.get(&conv.id).await.unwrap().unwrap().updated_at, + 5_000, + "insert must bump updated_at to the message time" + ); + + // A newer upsert (streaming tool-call update) advances recency. + let mut newer = sample_message(&conv.id); + newer.id = "tool-1".to_string(); + newer.created_at = 9_000; + repo.upsert_message(&newer).await.unwrap(); + assert_eq!( + repo.get(&conv.id).await.unwrap().unwrap().updated_at, + 9_000, + "newer upsert must advance updated_at" + ); + + // An out-of-order (older) upsert must NOT move recency backward (MAX guard). + let mut older = sample_message(&conv.id); + older.id = "tool-2".to_string(); + older.created_at = 3_000; + repo.upsert_message(&older).await.unwrap(); + assert_eq!( + repo.get(&conv.id).await.unwrap().unwrap().updated_at, + 9_000, + "older upsert must not move updated_at backward" + ); + } + #[tokio::test] async fn update_conversation_name() { let (repo, _db) = setup().await; diff --git a/crates/aionui-process/Cargo.toml b/crates/aionui-process/Cargo.toml new file mode 100644 index 000000000..5da148b1a --- /dev/null +++ b/crates/aionui-process/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "aionui-process" +version.workspace = true +edition.workspace = true + +# Self-contained subprocess mechanism (feature 001). Foundation layer: +# depends ONLY on aionui-common + aionui-runtime. Manages ONLY the processes +# it itself spawns — never touches the existing CliAgentProcess / registry. + +[dependencies] +aionui-common.workspace = true +aionui-runtime.workspace = true +async-trait.workspace = true +serde = { workspace = true } +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +uuid = { workspace = true, features = ["serde", "v4"] } +fs2.workspace = true +tokio = { workspace = true, features = ["process", "io-util", "sync", "rt", "macros", "time"] } +tokio-util = { version = "0.7", features = ["compat"] } + +[target.'cfg(unix)'.dependencies] +libc.workspace = true + +# Windows (feature 005 batch B): raw Win32 FFI (windows-sys, zero-overhead, NOT +# the heavy `windows` crate — Decision 2). Covers: +# - liveness/identity probe + cold-reap: OpenProcess / GetProcessTimes / +# WaitForSingleObject / TerminateProcess (Win32_System_Threading); +# - Job Object subtree containment (hot path): CreateJobObjectW / +# SetInformationJobObject(KILL_ON_JOB_CLOSE) / AssignProcessToJobObject / +# TerminateJobObject (Win32_System_JobObjects); the CREATE_SUSPENDED → assign +# → resume race-close needs toolhelp thread-walk (Win32_System_Diagnostics_ToolHelp). +# aionui-process owns the child's lifetime, so the Job handle lives here. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_Threading", + "Win32_System_JobObjects", + "Win32_System_Diagnostics_ToolHelp", +] } diff --git a/crates/aionui-process/src/capabilities.rs b/crates/aionui-process/src/capabilities.rs new file mode 100644 index 000000000..cdda12579 --- /dev/null +++ b/crates/aionui-process/src/capabilities.rs @@ -0,0 +1,136 @@ +//! Per-platform capability descriptor (feature 005, WORKFLOW discipline 7). +//! +//! Turns "what this crate can actually do on this OS" from scattered, silent +//! `cfg` branches into a single TYPED, ASSERTABLE value. The matrix in the 005 +//! design doc maps 1:1 to these fields; the `capabilities_matrix_per_platform` +//! test pins each platform's row, so a capability regression (e.g. someone +//! re-stubs macOS start-time to `None`) turns a test RED instead of silently +//! degrading reap safety. +//! +//! "Hot" vs "cold" kill is the load-bearing distinction (design I-9): while a +//! live `ManagedProcess` handle is held (normal exit / explicit kill / Drop) +//! the whole subtree is torn down on every platform; only the post-CRASH +//! cold-reap (reconstruct from a persisted pid, no live handle) degrades — and +//! only on Windows, where the Job handle does not survive the owner's death. + +use serde::{Deserialize, Serialize}; + +/// What kind of OS primitive contains a spawned subtree. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ContainmentKind { + /// No subtree containment (grandchildren are not corralled). + None, + /// POSIX process group (`setpgid` + `kill(-pgid)`); a `setsid` grandchild escapes. + ProcessGroup, + /// Windows Job Object (`KILL_ON_JOB_CLOSE` + `TerminateJobObject`); stronger than a group. + JobObject, +} + +/// How well crash-recovery reap (from a persisted pid, no live handle) works. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReapSupport { + /// No cross-restart reap on this platform. + None, + /// Full subtree reap survives restart (Unix: persisted pgid → `kill(-pgid)`). + Full, + /// Single-process kill after identity gating, plus a best-effort `taskkill /T` + /// sweep (Windows: the Job handle does not persist across the owner's death, + /// so the subtree guarantee degrades — design I-9). + SingleProcessGated, +} + +/// The subprocess-mechanism capabilities of the platform this binary was +/// compiled for. A `const fn` per-platform value — no runtime probing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Capabilities { + /// Can actively kill a process we spawned. + pub can_kill: bool, + /// What contains a spawned subtree. + pub subtree_containment: ContainmentKind, + /// `probe` can truthfully report liveness. + pub liveness_probe: bool, + /// `read_process_start_time` yields a real value (the reap-safety identity gate). + pub identity_gate: bool, + /// While a live handle is held (normal exit / kill / Drop), the WHOLE subtree + /// is torn down. True on every supported platform — no degradation here. + pub hot_kill_subtree: bool, + /// Crash-recovery reap quality (no live handle, from a persisted pid). + pub cold_reap: ReapSupport, + /// The kernel auto-kills our children when the parent dies (Linux + /// `PR_SET_PDEATHSIG` / Windows `KILL_ON_JOB_CLOSE`); shrinks crash orphans. + /// macOS has no equivalent. + pub parent_death_signal: bool, + /// Dropping a `ManagedProcess` reaps its subtree. + pub drop_reaps: bool, +} + +impl Capabilities { + /// The capabilities of the current compile target. + pub const fn current() -> Self { + #[cfg(target_os = "linux")] + { + Self { + can_kill: true, + subtree_containment: ContainmentKind::ProcessGroup, + liveness_probe: true, + identity_gate: true, // /proc//stat field 22 + hot_kill_subtree: true, + cold_reap: ReapSupport::Full, // persisted pgid → kill(-pgid) + parent_death_signal: true, // PR_SET_PDEATHSIG (R9) + drop_reaps: true, + } + } + #[cfg(target_os = "macos")] + { + Self { + can_kill: true, + subtree_containment: ContainmentKind::ProcessGroup, + liveness_probe: true, + identity_gate: true, // proc_pidinfo PROC_PIDTBSDINFO (R1) + hot_kill_subtree: true, + cold_reap: ReapSupport::Full, // persisted pgid → kill(-pgid) + parent_death_signal: false, // no PDEATHSIG equivalent; reaper is load-bearing + drop_reaps: true, + } + } + #[cfg(target_os = "windows")] + { + // BATCH B implemented (feature 005). Windows now has real: + // - probe + identity gate: OpenProcess + WaitForSingleObject + + // GetProcessTimes creation-FILETIME (proc_control windows_impl); + // - hot-kill subtree: Job Object (CREATE_SUSPENDED → assign → + // resume) + TerminateJobObject / KILL_ON_JOB_CLOSE on Drop; + // - parent-death: KILL_ON_JOB_CLOSE (job dies with the last handle). + // cold-reap stays SingleProcessGated (I-9): the Job handle does NOT + // persist across the owner's death, so a from-disk pid is terminated + // as a single process (TerminateProcess), not the whole subtree. + // ⚠️ Verified by cross-compile (cargo-xwin) + must be run on a real + // Windows host / UTM VM (no x86 CI lane) — until then treat the + // RUNTIME behavior as LocalVerifiedOnly in spirit. + Self { + can_kill: true, // TerminateJobObject / TerminateProcess + subtree_containment: ContainmentKind::JobObject, // Job Object + liveness_probe: true, // OpenProcess + WaitForSingleObject + identity_gate: true, // GetProcessTimes creation FILETIME + hot_kill_subtree: true, // Job terminate while handle held + cold_reap: ReapSupport::SingleProcessGated, // Job doesn't persist (I-9) + parent_death_signal: true, // KILL_ON_JOB_CLOSE + drop_reaps: true, // Drop terminates the Job + } + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + // Unknown platform: claim nothing (safe defaults — never kill on doubt). + Self { + can_kill: false, + subtree_containment: ContainmentKind::None, + liveness_probe: false, + identity_gate: false, + hot_kill_subtree: false, + cold_reap: ReapSupport::None, + parent_death_signal: false, + drop_reaps: false, + } + } + } +} diff --git a/crates/aionui-process/src/containment.rs b/crates/aionui-process/src/containment.rs new file mode 100644 index 000000000..f42cdb6df --- /dev/null +++ b/crates/aionui-process/src/containment.rs @@ -0,0 +1,69 @@ +//! Per-platform lifecycle fence (Containment). Tears down a whole subprocess +//! subtree (agent CLI + grandchildren like MCP servers), not just the direct +//! child. Lifecycle-only — orthogonal to any security sandbox. +//! +//! Single tier ships: [`ProcessGroupContainment`] (best-effort Unix process +//! group). Job Object / cgroup tiers are intentionally not built (no CI lane; +//! they collapse to the process-group kill on testable platforms). The seam +//! lets them land later without touching callers. + +use crate::ProcessError; + +/// Strength of a containment's teardown guarantee. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReapGuarantee { + /// Process-group SIGKILL: reaps descendants that stay in the group; misses + /// any that escaped via `setsid` (documented gap). + BestEffort, +} + +/// Outcome of [`Containment::kill_all`] — never a bare `Ok(())` the caller can +/// misread as "tree definitely gone". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContainmentKillOutcome { + /// Post-kill liveness probe confirmed the group is gone. + ProbedGone, + /// Kill issued but not confirmed gone (e.g. a member escaped the group). + DegradedBestEffort, +} + +/// A lifecycle fence around a spawned subprocess subtree. +pub trait Containment: Send + Sync { + fn kill_all(&self) -> Result; + fn guarantee(&self) -> ReapGuarantee; +} + +/// Best-effort containment via the Unix process group captured at spawn. +pub struct ProcessGroupContainment { + pid: u32, + process_group_id: Option, +} + +impl ProcessGroupContainment { + pub fn new(pid: u32, process_group_id: Option) -> Self { + Self { pid, process_group_id } + } +} + +impl Containment for ProcessGroupContainment { + fn kill_all(&self) -> Result { + crate::force_kill(self.pid, self.process_group_id)?; + // SIGKILL is async; give the kernel a brief bounded settle before the + // confirmation probe, else a clean kill almost always reads alive and + // ProbedGone would be unreachable. Still alive after settle => honest + // Degraded (escaped grandchild) rather than a false "gone". + const ATTEMPTS: u32 = 20; + const STEP: std::time::Duration = std::time::Duration::from_millis(25); + for _ in 0..ATTEMPTS { + if !crate::process_group_alive(self.process_group_id) { + return Ok(ContainmentKillOutcome::ProbedGone); + } + std::thread::sleep(STEP); + } + Ok(ContainmentKillOutcome::DegradedBestEffort) + } + + fn guarantee(&self) -> ReapGuarantee { + ReapGuarantee::BestEffort + } +} diff --git a/crates/aionui-process/src/error.rs b/crates/aionui-process/src/error.rs new file mode 100644 index 000000000..80ed39fa3 --- /dev/null +++ b/crates/aionui-process/src/error.rs @@ -0,0 +1,38 @@ +//! Mechanism-layer error. This crate is Foundation-layer and must not depend +//! on any domain error type; it owns a small enum covering only what the +//! spawn / lifecycle / reap mechanism produces. + +/// Errors produced by the subprocess mechanism layer. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ProcessError { + /// Invalid caller input (e.g. a missing / non-directory / whitespace cwd). + #[error("bad request: {0}")] + BadRequest(String), + /// Workspace path contains a whitespace segment the bundled runtime cannot handle. + #[error("workspace path contains whitespace (runtime unsupported): {0}")] + WorkspacePathContainsWhitespaceRuntimeUnsupported(String), + /// An OS / runtime failure (spawn failed, pipe capture failed, kill failed, fs error). + #[error("internal error: {0}")] + Internal(String), +} + +impl ProcessError { + pub fn bad_request(message: impl Into) -> Self { + Self::BadRequest(message.into()) + } + + pub fn workspace_path_contains_whitespace_runtime_unsupported(path: impl Into) -> Self { + Self::WorkspacePathContainsWhitespaceRuntimeUnsupported(path.into()) + } + + pub fn internal(message: impl Into) -> Self { + Self::Internal(message.into()) + } +} + +impl From for ProcessError { + fn from(e: std::io::Error) -> Self { + Self::Internal(e.to_string()) + } +} diff --git a/crates/aionui-process/src/instance_lock.rs b/crates/aionui-process/src/instance_lock.rs new file mode 100644 index 000000000..a62ca6e11 --- /dev/null +++ b/crates/aionui-process/src/instance_lock.rs @@ -0,0 +1,86 @@ +//! Single-instance advisory lock + per-run epoch (IC-1 defense / IC-3 naming). +//! +//! The lock file is a dedicated sidecar `{data_dir}/runtime/aionui-process/ +//! instance.lock` — provably disjoint from bun's `runtime.lock`, the node +//! install lock, the db migrate lock, and the builtin-skills lock (IC-3). It +//! is acquired NON-BLOCKING (try-lock) and fails fast on contention, so a +//! second overlapping instance (auto-update) never silently hangs and — more +//! importantly — never reaps the live sibling's processes (the reap pass is +//! gated on holding this lock). +//! +//! Each successful acquisition also mints a fresh per-run `instance_epoch` +//! (random v4 UUID). Equality-only: a registry row whose epoch differs from +//! the current run is a prior-run entry. No persistence, no ordering — so NTP +//! / reinstall can never make a stale epoch look current (IC-1, design Dec 1). + +use std::fs::File; +use std::path::{Path, PathBuf}; + +use fs2::FileExt; +use uuid::Uuid; + +use crate::registry_store::{LOCK_FILE, SUBDIR}; + +/// Held single-instance lock. Held for the whole process lifetime by keeping +/// this value alive; dropping it releases the advisory lock. +pub struct InstanceLock { + file: File, + path: PathBuf, +} + +impl InstanceLock { + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for InstanceLock { + fn drop(&mut self) { + // Explicit unlock for deterministic release. Closing the File would + // also release the flock, but on some platforms (macOS) the close- + // triggered release can lag; an explicit unlock() makes re-acquire by + // a successor deterministic. + let _ = fs2::FileExt::unlock(&self.file); + } +} + +/// Returned when the lock is already held by another instance. +#[derive(Debug)] +pub struct LockHeld { + pub path: PathBuf, +} + +impl std::fmt::Display for LockHeld { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "instance lock already held: {}", self.path.display()) + } +} + +/// Try to acquire the single-instance lock under `data_dir`. On success +/// returns the held lock + a freshly minted per-run epoch. On contention +/// returns `Err(LockHeld)` — the caller must NOT run any reap (IC-1: never +/// reap a live sibling instance's processes). +pub fn acquire_instance_lock(data_dir: &Path) -> Result<(InstanceLock, Uuid), LockHeld> { + let dir = data_dir.join(SUBDIR); + let path = dir.join(LOCK_FILE); + // create_dir_all is idempotent + benign under races (IC: already-isolated). + if std::fs::create_dir_all(&dir).is_err() { + return Err(LockHeld { path }); + } + // `File::create` sets `O_CLOEXEC` by default on unix (Rust std since 1.0), + // so this advisory-lock fd is NOT inherited by spawned children / MCP + // grandchildren. That matters: an inherited flock fd held by a surviving + // grandchild would keep the lock "held" after the parent dies, making the + // NEXT instance see contention and refuse to reap — defeating cold-reap. + // CLOEXEC closes that hole for free; the `cloexec_lock_fd_not_inherited` + // test guards against a future switch to a non-CLOEXEC open path. + let file = match File::create(&path) { + Ok(f) => f, + Err(_) => return Err(LockHeld { path }), + }; + // NON-BLOCKING: fail fast on contention, never block (IC-3). + match file.try_lock_exclusive() { + Ok(()) => Ok((InstanceLock { file, path }, Uuid::new_v4())), + Err(_) => Err(LockHeld { path }), + } +} diff --git a/crates/aionui-process/src/lib.rs b/crates/aionui-process/src/lib.rs new file mode 100644 index 000000000..7892e4c83 --- /dev/null +++ b/crates/aionui-process/src/lib.rs @@ -0,0 +1,42 @@ +//! `aionui-process` — self-contained subprocess mechanism (feature 001). +//! +//! A Foundation-layer crate that spawns, supervises, and reaps the agent +//! subprocesses **it itself starts** — fully parallel to and unaware of the +//! existing `CliAgentProcess` / process registry in `aionui-ai-agent`. +//! +//! "Bytes not semantics": it never parses agent output, holds no session +//! state, and never mutates `std::env`. It depends only on `aionui-common` +//! and `aionui-runtime`. +//! +//! ## Isolation contract (why two mechanisms coexist without conflict) +//! All shared resources are namespaced under `{data_dir}/runtime/aionui-process/` +//! and every kill is identity-gated against a recorded process start-time so a +//! recycled PID/PGID is never mistaken for one of ours. See the feature +//! design doc §Isolation-Contract (IC-1..6). + +mod capabilities; +mod containment; +mod error; +mod instance_lock; +mod proc_control; +mod process; +mod registry_store; +mod spawner; +mod supervisor; + +pub use capabilities::{Capabilities, ContainmentKind, ReapSupport}; +pub use containment::{Containment, ContainmentKillOutcome, ProcessGroupContainment, ReapGuarantee}; +pub use error::ProcessError; +pub use instance_lock::{InstanceLock, LockHeld, acquire_instance_lock}; +pub use proc_control::{ + Liveness, ObservedLiveness, classify_liveness, force_kill, probe, process_group_alive, read_process_start_time, +}; +pub use process::{BoxedStdin, BoxedStdout, ManagedProcess, TerminalExit}; +pub use registry_store::{ + FileRegistryStore, LOCK_FILE, ProcessIdentity, REGISTRY_FILE, RegisteredProcess, RegistryStore, SUBDIR, +}; +pub use spawner::{RealSpawner, Spawner, local_machine_id}; +pub use supervisor::{ + Action, LockState, ObservedState, execute_actions, gather_observed, reconcile, reconcile_with_capability, + run_startup_reap, +}; diff --git a/crates/aionui-process/src/proc_control.rs b/crates/aionui-process/src/proc_control.rs new file mode 100644 index 000000000..a5ad4fb11 --- /dev/null +++ b/crates/aionui-process/src/proc_control.rs @@ -0,0 +1,467 @@ +//! Kill + identity-gated liveness probing (IC-1 core). +//! +//! The crate spawns each child as its own process-group leader, so teardown +//! can `kill(-pgid, SIGKILL)` the whole subtree. Before reaping a *persisted* +//! pgid (which may have been recycled by the OS onto an unrelated live +//! process), we re-read the live process start-time and require it to MATCH +//! what we recorded — otherwise we prune the registry entry and never kill. +//! +//! `pgid > 1` blast-floor everywhere (IC-6): never `kill(-1)` / `kill(-0)`. + +use uuid::Uuid; + +use crate::ProcessError; + +/// Result of an identity-gated liveness probe (the pure decision input). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Liveness { + /// Live, recorded epoch differs from current (a prior-run orphan), and the + /// process start-time matches what we recorded — safe to reap. + Match, + /// A live process holds the pid, but its start-time differs from what we + /// recorded → the PID was recycled onto an unrelated process. Prune only. + RecycledPid, + /// Live and start-time matches, but the recorded epoch == current epoch → + /// this is one of THIS run's own processes, never a reap target. + DiffEpoch, + /// No process holds the pid (ESRCH). Prune only. + Gone, + /// Could not determine start-time on this platform (macOS/Windows fallback) + /// or the recorded start-time was absent. Prune only — never kill on doubt. + Unknown, + /// `kill(pid, 0)` returned EPERM: some process holds the id but it is not + /// provably ours. Prune only. + EpermAlive, +} + +/// Pure identity-gate decision. Reads NOTHING from the OS — the caller supplies +/// the observed start-time (from [`read_process_start_time`]). This is the +/// Tier-A-exhaustible core of IC-1. +/// +/// Reap is permitted ONLY when the process is alive, its start-time matches the +/// recorded one, and the recorded epoch is from a *prior* run. +pub fn classify_liveness( + recorded_start_ticks: Option, + observed: ObservedLiveness, + recorded_epoch: Uuid, + current_epoch: Uuid, +) -> Liveness { + match observed { + ObservedLiveness::Gone => Liveness::Gone, + ObservedLiveness::EpermAlive => Liveness::EpermAlive, + ObservedLiveness::Alive { start_ticks } => { + match (recorded_start_ticks, start_ticks) { + // We could not record or cannot observe a start-time → never kill. + (None, _) | (_, None) => Liveness::Unknown, + // F57: `0` is a NON-identity sentinel. A real recorder never + // emits 0 as a legitimate discriminator (Linux starttime jiffies + // and the macOS (tvsec<<20)|tvusec packing are non-zero for any + // post-boot process); a `Some(0)` therefore means corrupt / + // zeroed data. Treating two zeros as a Match would AUTHORIZE A + // KILL on garbage — the one value where the safe-failing argument + // breaks. Refuse to gate on 0 → Unknown (prune-only, never kill). + (Some(0), _) | (_, Some(0)) => Liveness::Unknown, + (Some(rec), Some(obs)) if rec == obs => { + if recorded_epoch == current_epoch { + Liveness::DiffEpoch // our own current-run process + } else { + Liveness::Match // prior-run orphan, identity-confirmed + } + } + // Same pid, different start-time → recycled. + (Some(_), Some(_)) => Liveness::RecycledPid, + } + } + } +} + +/// What an OS probe observed about a pid, fed into [`classify_liveness`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObservedLiveness { + /// The pid is live; `start_ticks` is its kernel start-time if obtainable. + Alive { start_ticks: Option }, + /// No process holds the pid (ESRCH). + Gone, + /// `kill(pid, 0)` returned EPERM. + EpermAlive, +} + +/// Probe a pid's liveness + start-time via the OS (the impure shell around +/// [`classify_liveness`]). +pub fn probe(pid: u32) -> ObservedLiveness { + #[cfg(unix)] + { + // Refuse to wrap: a pid that doesn't fit positive i32 would, as a + // negative target, probe a GROUP instead of the process (F50). Treat as + // not-observable → Gone (prune-only; never authorizes a kill). + let Some(target) = pid_signal_target(pid) else { + return ObservedLiveness::Gone; + }; + let rc = unsafe { libc::kill(target, 0) }; + if rc == 0 { + return ObservedLiveness::Alive { + start_ticks: read_process_start_time(pid), + }; + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => ObservedLiveness::Gone, + Some(libc::EPERM) => ObservedLiveness::EpermAlive, + _ => ObservedLiveness::Gone, + } + } + #[cfg(windows)] + { + windows_impl::probe(pid) + } + #[cfg(not(any(unix, windows)))] + { + let _ = pid; + // No cheap probe on an unknown platform; Unknown-alive so the gate prunes. + ObservedLiveness::Alive { start_ticks: None } + } +} + +/// Read a process's kernel start-time in platform clock ticks, if obtainable. +/// `None` on platforms where we have no cheap accessor (→ identity gate +/// downgrades to Unknown → prune-only). +pub fn read_process_start_time(pid: u32) -> Option { + #[cfg(target_os = "linux")] + { + // /proc//stat field 22 (starttime), after the (comm) field which + // may itself contain spaces/parens — split on the last ')'. + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let after = stat.rsplit_once(')')?.1; + let starttime = after.split_whitespace().nth(19)?; // field 22 = index 19 after comm + starttime.parse::().ok() + } + #[cfg(target_os = "macos")] + { + read_start_time_macos(pid) + } + #[cfg(target_os = "windows")] + { + windows_impl::read_start_time(pid) + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + // Unknown platform: no cheap accessor → None keeps the identity gate + // conservative (prune, never kill on doubt). + let _ = pid; + None + } +} + +/// macOS kernel process start-time via `proc_pidinfo(PROC_PIDTBSDINFO)`, packed +/// as `(tvsec << 20) | (tvusec & 0xF_FFFF)` to keep sub-second resolution in a +/// single `u64` without touching the platform-agnostic pure core (design +/// Decision 1/5). `tvusec` < 1_000_000 < 2^20, so the low 20 bits hold it +/// losslessly; the comparison is equality-only so the exact packing is opaque +/// to `classify_liveness`. `None` on any failure → identity gate stays +/// conservative (prune, never kill). +#[cfg(target_os = "macos")] +fn read_start_time_macos(pid: u32) -> Option { + // SAFETY: proc_pidinfo writes at most `size` bytes into `info`; we pass the + // exact size of the zeroed struct and only read it back on the documented + // success return (bytes-written == struct size). + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as libc::c_int; + let n = unsafe { + libc::proc_pidinfo( + pid as libc::c_int, + libc::PROC_PIDTBSDINFO, + 0, + &mut info as *mut _ as *mut libc::c_void, + size, + ) + }; + if n != size { + // 0 / -1 / short read → could not obtain a trustworthy start-time. + return None; + } + let secs = info.pbi_start_tvsec; + let usecs = info.pbi_start_tvusec & 0xF_FFFF; // < 2^20, fits the low 20 bits + Some((secs << 20) | usecs) +} + +/// The reaper's OWN process group id, if obtainable. Used to refuse killing a +/// pgid we ourselves belong to (would self-SIGKILL). `None` on non-unix or if +/// the value would not fit a `u32` (defensive). +pub(crate) fn current_process_group() -> Option { + #[cfg(unix)] + { + let pgrp = unsafe { libc::getpgrp() }; + u32::try_from(pgrp).ok() + } + #[cfg(not(unix))] + { + None + } +} + +/// The `libc::kill` target for SIGNALLING A SINGLE PID: the pid as a positive +/// `i32`. Returns `None` if the pid does not fit in the positive `i32` range +/// (≥ 2^31) — `pid as i32` would wrap NEGATIVE and accidentally signal a process +/// GROUP instead of the single process (F50). Pure; unit-tested. +#[cfg(unix)] +pub(crate) fn pid_signal_target(pid: u32) -> Option { + i32::try_from(pid).ok().filter(|t| *t > 0) +} + +/// The `libc::kill` target for SIGNALLING A WHOLE GROUP: the negative pgid. +/// Returns `None` if pgid ≤ 1 (IC-6 blast-floor) OR pgid ≥ 2^31 — in the latter +/// case `pgid as i32` wraps negative and `-(negative)` becomes POSITIVE, so the +/// group-kill would silently collapse into a single-PID kill and leak the +/// grandchild subtree (F50). Pure; unit-tested. +#[cfg(unix)] +pub(crate) fn group_kill_target(pgid: u32) -> Option { + if pgid <= 1 { + return None; + } + // Must fit positive i32 so that negation is a well-defined negative target. + i32::try_from(pgid).ok().map(|p| -p) +} + +/// Force-kill a process group (or the bare pid if no group), best-effort. +/// `ESRCH` (already gone) is treated as success. Honors the `pgid > 1` +/// blast-floor (IC-6) and refuses to wrap large ids (F50). +pub fn force_kill(pid: u32, process_group_id: Option) -> Result<(), ProcessError> { + #[cfg(unix)] + { + if let Some(target) = process_group_id.and_then(group_kill_target) { + return kill_target(target).or_else(|e| { + // Group leader may already be gone; fall back to the bare pid. + if e.already_gone { + match pid_signal_target(pid) { + Some(t) => kill_target(t).map_err(|e| e.into()), + None => Ok(()), // pid out of i32 range — nothing safe to do + } + } else { + Err(e.into()) + } + }); + } + match pid_signal_target(pid) { + Some(t) => kill_target(t).map_err(Into::into), + None => Err(ProcessError::internal(format!("pid {pid} out of signalable i32 range"))), + } + } + #[cfg(windows)] + { + // Windows COLD-REAP path (from a persisted registry pid, no live Job + // handle): single-process TerminateProcess. The Job Object does NOT + // persist across the owner's death, so a recycled-from-disk pid can only + // be terminated as a single process — grandchildren are not reachable + // here (I-9, documented). HOT kill (live ManagedProcess in hand) goes + // through the Job in process.rs and DOES kill the whole subtree. + let _ = process_group_id; // no pgid concept on Windows + windows_impl::terminate_process(pid) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (pid, process_group_id); + Err(ProcessError::internal("force_kill not supported on this platform")) + } +} + +#[cfg(unix)] +struct KillErr { + already_gone: bool, + msg: String, +} + +#[cfg(unix)] +impl From for ProcessError { + fn from(e: KillErr) -> Self { + ProcessError::internal(e.msg) + } +} + +#[cfg(unix)] +fn kill_target(target: i32) -> Result<(), KillErr> { + let rc = unsafe { libc::kill(target, libc::SIGKILL) }; + if rc == 0 { + return Ok(()); + } + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { + return Ok(()); // already gone == success + } + Err(KillErr { + already_gone: false, + msg: format!("SIGKILL to {target} failed: {err}"), + }) +} + +/// Liveness of a process group by signal-0 to the negative pgid (Unix). +/// `true` if the group still has any member. Non-Unix / no pgid → false. +pub fn process_group_alive(process_group_id: Option) -> bool { + #[cfg(unix)] + { + match process_group_id.and_then(group_kill_target) { + Some(target) => { + let rc = unsafe { libc::kill(target, 0) }; + if rc == 0 { + return true; + } + !matches!(std::io::Error::last_os_error().raw_os_error(), Some(libc::ESRCH)) + } + None => false, + } + } + #[cfg(not(unix))] + { + // Windows has no process-group concept (containment is a Job Object, and + // `process_group_id` is always None here). Group-liveness is therefore + // not meaningful; callers needing Windows liveness use `probe(pid)` + // instead. Returns false (no group to be alive). + let _ = process_group_id; + false + } +} + +/// Windows Win32 FFI: liveness/identity probe + single-process termination +/// (cold-reap). Job Object subtree containment lives in aionui-runtime's +/// spawn.rs (it owns the Command). windows-sys raw FFI (Decision 2). +#[cfg(windows)] +mod windows_impl { + use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_ACCESS_DENIED, FILETIME, GetLastError, WAIT_OBJECT_0, WAIT_TIMEOUT, + }; + use windows_sys::Win32::System::Threading::{ + GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, TerminateProcess, + WaitForSingleObject, + }; + + use super::{ObservedLiveness, ProcessError}; + + /// `SYNCHRONIZE` access right (0x0010_0000). Required for `WaitForSingleObject` + /// on a process handle. windows-sys only re-exports the const under + /// `Storage::FileSystem` (typed `FILE_ACCESS_RIGHTS`), so we use the literal — + /// it is the standard-rights bit, identical across object types. + const SYNCHRONIZE: u32 = 0x0010_0000; + + /// Open a handle with the given access. `Ok(Some(h))` = opened (caller MUST + /// CloseHandle it — it is OUR handle, NOT a tokio-owned Child handle). + /// `Ok(None)` = the pid is genuinely gone (ERROR-not-access-denied). + /// `Err(())` = access denied (a process holds the id but we may not query it). + fn open(pid: u32, access: u32) -> Result, ()> { + // SAFETY: OpenProcess returns a handle or null on failure. + let h = unsafe { OpenProcess(access, 0, pid) }; + if !h.is_null() { + return Ok(Some(h)); + } + // SAFETY: GetLastError reads the calling thread's last error. + let err = unsafe { GetLastError() }; + if err == ERROR_ACCESS_DENIED { Err(()) } else { Ok(None) } + } + + /// Liveness + identity (creation-time) probe. + /// + /// The handle MUST be opened with `SYNCHRONIZE` in addition to + /// `PROCESS_QUERY_LIMITED_INFORMATION` — `WaitForSingleObject` requires + /// SYNCHRONIZE, and without it the wait returns `WAIT_FAILED`, which would + /// make a LIVE process look gone (the real-Windows bug this fixes: a fresh + /// child probed as `Gone`). + /// + /// `OpenProcess` null + access-denied ⇒ `EpermAlive` (something holds the id, + /// not provably ours). `OpenProcess` null + other ⇒ `Gone`. + /// `WaitForSingleObject == WAIT_TIMEOUT` ⇒ still running (Alive); + /// `WAIT_OBJECT_0` (signaled) ⇒ exited (Gone). Any other wait result + /// (incl. WAIT_FAILED) is treated conservatively as Gone but only AFTER we + /// confirmed the handle opened — a real failure here is logged by the caller. + pub(super) fn probe(pid: u32) -> ObservedLiveness { + let h = match open(pid, PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE) { + Ok(Some(h)) => h, + Ok(None) => return ObservedLiveness::Gone, + Err(()) => return ObservedLiveness::EpermAlive, + }; + // SAFETY: h is a valid handle (opened with SYNCHRONIZE) we own; closed below. + let wait = unsafe { WaitForSingleObject(h, 0) }; + let start = read_creation_token(h); + // SAFETY: close the handle WE opened (not a tokio Child handle). + unsafe { CloseHandle(h) }; + if wait == WAIT_TIMEOUT { + // Still running. Avoids the GetExitCodeProcess STILL_ACTIVE(259) + // ambiguity by using WaitForSingleObject as the liveness oracle. + ObservedLiveness::Alive { start_ticks: start } + } else if wait == WAIT_OBJECT_0 { + ObservedLiveness::Gone // signaled = exited + } else { + // WAIT_FAILED or unexpected — conservative Gone (prune-only, never + // authorizes a kill). With SYNCHRONIZE present this should not occur + // for a live process. + ObservedLiveness::Gone + } + } + + /// Creation-time identity token: combine the FILETIME's two u32 halves into + /// a u64 (100ns ticks since 1601). Stable for the process's lifetime; a + /// recycled PID gets a different creation time → the identity gate's + /// `(pid, token)` pair defeats PID reuse. `None` on any failure. + pub(super) fn read_start_time(pid: u32) -> Option { + // GetProcessTimes needs only QUERY_LIMITED (no SYNCHRONIZE). open() returns + // Ok(Some)=opened / Ok(None)=gone / Err=access-denied; only Some yields a token. + let h = match open(pid, PROCESS_QUERY_LIMITED_INFORMATION) { + Ok(Some(h)) => h, + Ok(None) | Err(()) => return None, + }; + let token = read_creation_token(h); + // SAFETY: close the handle we opened. + unsafe { CloseHandle(h) }; + token + } + + fn read_creation_token(h: *mut core::ffi::c_void) -> Option { + let mut creation = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut exit = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut kernel = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut user = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + // SAFETY: all four out-params are valid; h is a live handle. + let ok = unsafe { GetProcessTimes(h, &mut creation, &mut exit, &mut kernel, &mut user) }; + if ok == 0 { + return None; + } + let token = ((creation.dwHighDateTime as u64) << 32) | (creation.dwLowDateTime as u64); + // A zeroed creation time is not a trustworthy identity (F57: 0 is the + // non-identity sentinel the pure gate refuses); treat as unobtainable. + if token == 0 { None } else { Some(token) } + } + + /// Cold-reap single-process termination (I-9) via `OpenProcess(PROCESS_TERMINATE)` + /// then `TerminateProcess`. A pid that is already gone (OpenProcess fails) is + /// success. Does NOT reach grandchildren — that needs the live Job handle + /// (hot path), which a cold reap from disk does not have. + pub(super) fn terminate_process(pid: u32) -> Result<(), ProcessError> { + let h = match open(pid, PROCESS_TERMINATE) { + Ok(Some(h)) => h, + // already gone == success (like ESRCH); access-denied → nothing safe + // to do, also treat as success (best-effort cold reap). + Ok(None) | Err(()) => return Ok(()), + }; + // SAFETY: h is a valid handle with PROCESS_TERMINATE; closed below. + let ok = unsafe { TerminateProcess(h, 1) }; + let err = if ok == 0 { + Some(std::io::Error::last_os_error()) + } else { + None + }; + // SAFETY: close the handle we opened. + unsafe { CloseHandle(h) }; + match err { + None => Ok(()), + Some(e) => Err(ProcessError::internal(format!("TerminateProcess({pid}) failed: {e}"))), + } + } +} diff --git a/crates/aionui-process/src/process.rs b/crates/aionui-process/src/process.rs new file mode 100644 index 000000000..abf950e2e --- /dev/null +++ b/crates/aionui-process/src/process.rs @@ -0,0 +1,631 @@ +//! `ManagedProcess` — the live handle for a subprocess THIS crate spawned. +//! +//! Freshly written (not moved from the existing CliAgentProcess). Owns the +//! child's raw stdio for byte-duplex handoff (IC: bytes not semantics — it +//! never parses output), a watch latch that flips to the exit status, a +//! bounded stderr ring buffer for diagnostics, and the background tasks that +//! drain stderr + monitor exit. Reaping is EXCLUSIVELY via tokio's per-Child +//! wait() (IC-2: no waitpid(-1) / no SIGCHLD handler / no raw Command). + +use std::path::PathBuf; +use std::process::ExitStatus; +use std::sync::Arc; +use std::time::Duration; + +use aionui_common::CommandSpec; +use aionui_runtime::Builder; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{ChildStdin, ChildStdout}; +use tokio::sync::{Mutex, watch}; +use tokio::task::JoinHandle; +use tracing::{debug, error, warn}; + +use crate::ProcessError; + +/// Max bytes retained in the stderr ring buffer (diagnostics only). +const STDERR_BUFFER_MAX: usize = 8192; + +/// Boxed stdio handed to a transport (type erased so the handle type — real +/// ChildStdin/out vs a fake duplex — is hidden). +pub type BoxedStdin = Box; +pub type BoxedStdout = Box; + +/// Terminal exit state held in the exit watch. `None` in the watch = STILL +/// RUNNING; `Some(_)` = terminally gone. The two `Some` variants distinguish a +/// real status from a `wait()` that errored — the distinction the old +/// `Option` payload could not express (F45): on a `wait()` error the +/// old code left the payload `None`, making a terminally-gone process look like +/// "still running" to `exit_status()`/`kill()` and causing a spurious 5s +/// "did not exit" timeout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalExit { + /// `wait()` returned a status (the normal case). + Exited(ExitStatus), + /// `wait()` itself errored (e.g. ECHILD — another reaper got there first). + /// The process is terminally gone but with no obtainable status. + WaitErrored, +} + +/// A registry-deregistration (or other cleanup) hook fired exactly once when the +/// `ManagedProcess` is dropped. Opaque to this crate ("bytes not semantics" — it +/// never knows about RegistryStore); injected by the assembly layer (RealSpawner) +/// so a clean turn-end drop removes the registry row (F38) without this module +/// depending on the registry. The hook MUST NOT block the calling thread — the +/// injector offloads any file I/O (e.g. to `spawn_blocking`). +type OnDropHook = Box; + +/// A live subprocess this crate owns. +pub struct ManagedProcess { + stdin: Mutex>, + stdout: Mutex>, + pid: u32, + process_group_id: Option, + exit_rx: watch::Receiver>, + stderr_buffer: Arc>>, + stderr_task: JoinHandle<()>, + /// The exit-monitor task. Held only to keep it attached for the process's + /// lifetime; on Drop it is DETACHED (not aborted) so it completes the single + /// `child.wait()` reap of the direct child after the group-kill — one reaper, + /// no manual `waitpid`, no blocking sleep (F43/F44). `Option` so Drop can + /// `take()` it and explicitly detach (documents intent + silences "unread"). + exit_task: Option>, + /// Fired once on Drop (registry dereg, F38). `Mutex` so `&self` Drop can take it. + on_drop: std::sync::Mutex>, + /// Windows Job Object holding this child + all its descendants (batch B). + /// Held for the child's whole lifetime; on Drop it terminates the whole + /// subtree (KILL_ON_JOB_CLOSE). `None` only if assignment failed (the spawn + /// then errors before this struct is built, so in practice always `Some`). + #[cfg(windows)] + job: Option, +} + +impl Drop for ManagedProcess { + /// Owning-handle contract: dropping a `ManagedProcess` tears down the + /// subprocess subtree it owns — WITHOUT blocking the calling thread (which, + /// in the consumer, is a tokio worker; F43) and WITHOUT a second reaper + /// racing the exit task (F44). + /// + /// Steps: (1) group-SIGKILL the whole subtree if not already terminally gone + /// (covers grandchildren in the group; `force_kill` maps already-gone→Ok so a + /// normal post-exit drop is a no-op); (2) abort the stderr task; (3) detach + /// the exit-monitor task by dropping its handle; (4) fire the once-only + /// on-drop hook (registry dereg, F38). + /// + /// WHO REAPS THE DIRECT CHILD'S ZOMBIE (corrected per review): + /// the authoritative reaper is tokio's `kill_on_drop(true)` (set on the + /// Builder Child in aionui-runtime spawn.rs) — when the exit-monitor task + /// (which OWNS the `Child`) is dropped, tokio's orphan queue reaps the Child. + /// So we do NOT rely on the detached `child.wait()` running to completion + /// (a detached task is cancelled at its next await on runtime shutdown, NOT + /// driven to completion — the earlier claim here was overstated). We detach + /// rather than `abort()` purely so a turn-end drop (runtime still alive) lets + /// the in-flight `wait()` finish naturally; either way kill_on_drop guarantees + /// the reap. The point that IS load-bearing (F43/F44): Drop does NO blocking + /// `std::thread::sleep` and NO manual `waitpid`, so it never stalls the + /// (tokio worker) thread releasing the last Arc and there is no second reaper + /// racing tokio's. + /// + /// `already_terminal` now reads the watch as a real liveness check (F45): + /// `Some(_)` — whether `Exited` or `WaitErrored` — means terminally gone, so + /// a `wait()`-error no longer makes a dead process look alive and trigger a + /// redundant group-kill. + fn drop(&mut self) { + let already_terminal = self.exit_rx.borrow().is_some(); + if !already_terminal { + // HOT-path whole-subtree kill. Non-blocking. + // + // F41 (why no identity re-gate on the HOT path, unlike cold reap): + // the exit-monitor task still holds the tokio `Child`, which OWNS + // this pid — the kernel cannot recycle a pid until it is + // `wait()`-reaped, and that reap only happens inside exit_task (which + // we detach, not abort). So while this handle is live the pgid/job + // CANNOT have been recycled onto an innocent process; killing our own + // recorded group/job here is safe by Child-ownership, no start-time + // re-check needed. The identity gate is required only for the COLD + // reap path (no live Child, pid reconstructed from disk), where it + // exists (F42). `already_terminal` uses the F45-correct watch so a + // wait()-errored (terminally gone) process is not re-killed. + #[cfg(unix)] + let _ = crate::force_kill(self.pid, self.process_group_id); + // Windows HOT kill = terminate the whole Job (subtree), not just the + // single process. The Job's own Drop (below, KILL_ON_JOB_CLOSE) would + // also do this, but an explicit synchronous terminate here guarantees + // teardown before the dereg hook fires. + #[cfg(windows)] + if let Some(job) = &self.job { + job.terminate(); + } + } + // Stop draining stderr eagerly (its fd/buffer/task would otherwise be + // pinned by a surviving grandchild holding the write end; F48). + self.stderr_task.abort(); + // DETACH the exit task (do NOT abort): take it out and drop it. Dropping + // a tokio `JoinHandle` lets the task run to completion, so the single + // `child.wait()` inside it completes the lone reap of the direct child + // after the group-kill above — one reaper, no manual `waitpid`, no + // `std::thread::sleep`, no blocking on this (worker) thread (F43/F44). + if let Some(task) = self.exit_task.take() { + drop(task); // explicit detach + } + + // Fire the dereg hook exactly once (registry dereg, F38). The injector + // (RealSpawner) makes the hook non-blocking (offloads file I/O), so this + // does not stall the dropping thread. + if let Some(hook) = self.on_drop.lock().unwrap_or_else(|e| e.into_inner()).take() { + hook(); + } + } +} + +impl ManagedProcess { + /// Spawn `spec` as its own process-group leader and start the background + /// stderr-drain + exit-monitor tasks. `extra_env` is applied per-child via + /// the Builder (IC-5: never mutates global env). + pub async fn spawn(spec: CommandSpec, extra_env: &[(String, String)]) -> Result { + let cwd = match spec.cwd.as_deref() { + Some(c) => Some(prepare_command_cwd(c)?), + None => None, + }; + + let mut builder = Builder::new(&spec.command); + builder + .args(&spec.args) + .envs(spec.env.iter().map(|e| (&e.name, &e.value))) + .envs(extra_env.iter().map(|(k, v)| (k, v))) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + if let Some(cwd) = cwd { + builder.current_dir(cwd); + } + + let mut child = builder + .spawn() + .map_err(|e| ProcessError::internal(format!("spawn '{}' failed: {e}", spec.command.display())))?; + + let pid = child + .id() + .ok_or_else(|| ProcessError::internal("spawned child has no pid"))?; + let process_group_id = tracked_process_group_id(pid); + + // Windows (batch B): the child was spawned CREATE_SUSPENDED. Assign it to + // a Job Object (subtree containment) and resume it BEFORE we touch its + // stdio — closing the assign-vs-fork race. On failure we must NOT leave a + // suspended zombie (S7): kill it and surface the error. + #[cfg(windows)] + let job = { + // raw_handle() is valid while the child is alive (it is, suspended). + match child.raw_handle() { + Some(raw) => match job_windows::JobObject::assign_and_resume(raw, pid) { + Ok(j) => Some(j), + Err(e) => { + // Tear down the suspended child (kill_on_drop SIGKILLs it + // when `child` drops at end of scope) and error out (S7). + let _ = child.start_kill(); + return Err(ProcessError::internal(format!("windows job containment: {e}"))); + } + }, + None => { + let _ = child.start_kill(); + return Err(ProcessError::internal( + "windows: child has no raw handle for job assignment", + )); + } + } + }; + + let stdin = child + .stdin + .take() + .ok_or_else(|| ProcessError::internal("failed to capture stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| ProcessError::internal("failed to capture stdout"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| ProcessError::internal("failed to capture stderr"))?; + + // Background: drain stderr into a bounded ring buffer. + let stderr_buffer = Arc::new(Mutex::new(Vec::::new())); + let buf_clone = Arc::clone(&stderr_buffer); + let stderr_task = tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let trimmed = line.trim(); + if !trimmed.is_empty() { + warn!(pid, stderr = trimmed, "subprocess stderr"); + } + let mut buf = buf_clone.lock().await; + buf.extend_from_slice(line.as_bytes()); + buf.push(b'\n'); + if buf.len() > STDERR_BUFFER_MAX { + // Truncate by raw byte offset (UTF-8-safe: we never index + // mid-char, we drain a byte prefix of a Vec). + let cut = buf.len() - STDERR_BUFFER_MAX; + buf.drain(..cut); + } + } + debug!(pid, "stderr drain finished"); + }); + + // Background: monitor exit via tokio's per-Child wait (IC-2). The watch + // payload is `Option`: `None` = still running, `Some(_)` = + // terminally gone. A `wait()` error latches `Some(WaitErrored)` — NOT + // `None` — so `exit_status()`/`kill()` see a real terminal state instead + // of conflating "wait errored" with "never exited" (F45). + let (exit_tx, exit_rx) = watch::channel(None); + // Hand the exit task an abort handle to the stderr drain so it can stop + // it the moment the direct child is gone — otherwise a grandchild holding + // the stderr write end keeps the drain (fd + 8KB buffer + task) pinned + // until the ManagedProcess is dropped (F48). + let stderr_abort = stderr_task.abort_handle(); + let exit_task = tokio::spawn(async move { + match child.wait().await { + Ok(status) => { + debug!(pid, ?status, "subprocess exited"); + let _ = exit_tx.send(Some(TerminalExit::Exited(status))); + } + Err(e) => { + error!(pid, error = %e, "wait() on subprocess errored"); + let _ = exit_tx.send(Some(TerminalExit::WaitErrored)); + } + } + // Direct child is gone: stop the stderr drain so a surviving + // grandchild on the same pipe cannot pin the fd/task (F48). + stderr_abort.abort(); + }); + + Ok(Self { + stdin: Mutex::new(Some(stdin)), + stdout: Mutex::new(Some(stdout)), + pid, + process_group_id, + exit_rx, + stderr_buffer, + stderr_task, + exit_task: Some(exit_task), + on_drop: std::sync::Mutex::new(None), + #[cfg(windows)] + job, + }) + } + + /// Install a once-only cleanup hook fired on Drop (registry dereg, F38). + /// Opaque to this crate; the injector (RealSpawner) must make it non-blocking. + /// Overwrites any previously-set hook (last writer wins; intended single set). + pub fn set_on_drop(&self, hook: Box) { + *self.on_drop.lock().unwrap_or_else(|e| e.into_inner()) = Some(hook); + } + + /// Hand off stdin+stdout (boxed) to a transport. Once-only: a second call + /// returns None. Takes both under one critical section so it is all-or- + /// nothing — a partial handoff never leaves one taken and one intact. + pub async fn take_stdio(&self) -> Option<(BoxedStdin, BoxedStdout)> { + let mut sin = self.stdin.lock().await; + let mut sout = self.stdout.lock().await; + match (sin.is_some(), sout.is_some()) { + (true, true) => { + let i = sin.take().unwrap(); + let o = sout.take().unwrap(); + Some((Box::new(i), Box::new(o))) + } + _ => None, // already taken (or never present) — leave both as-is + } + } + + pub fn pid(&self) -> u32 { + self.pid + } + + pub fn process_group_id(&self) -> Option { + self.process_group_id + } + + /// The exit status if the process has terminally exited WITH an obtainable + /// status. `None` means EITHER still running OR `wait()` errored + /// (`WaitErrored`) — callers needing to distinguish those use + /// [`Self::terminal_exit`] / [`Self::has_exited`]. + pub fn exit_status(&self) -> Option { + match *self.exit_rx.borrow() { + Some(TerminalExit::Exited(s)) => Some(s), + Some(TerminalExit::WaitErrored) | None => None, + } + } + + /// The full terminal state: `None` = still running; `Some(Exited)` / + /// `Some(WaitErrored)` = terminally gone. This is the F45-correct liveness + /// signal (a `WaitErrored` process is GONE, not running). + pub fn terminal_exit(&self) -> Option { + *self.exit_rx.borrow() + } + + /// True iff the process is terminally gone (exited or wait-errored). + pub fn has_exited(&self) -> bool { + self.exit_rx.borrow().is_some() + } + + /// Resolve when the process is terminally gone (exited or wait() errored). + /// Returns the status if one was obtainable (`None` for `WaitErrored`). + pub async fn wait_for_exit(&self) -> Option { + let mut rx = self.exit_rx.clone(); + if rx.borrow().is_some() { + return self.exit_status(); + } + let _ = rx.changed().await; + self.exit_status() + } + + /// Peek the last `max_lines` lines of buffered stderr without draining. + pub async fn peek_stderr_tail(&self, max_lines: usize) -> String { + if max_lines == 0 { + return String::new(); + } + let buf = self.stderr_buffer.lock().await; + // Lossy render — diagnostics channel, never the byte-duplex contract. + let text = String::from_utf8_lossy(&buf); + let trimmed = text.trim_end_matches('\n'); + if trimmed.is_empty() { + return String::new(); + } + let mut tail: Vec<&str> = trimmed.rsplit('\n').take(max_lines).collect(); + tail.reverse(); + tail.join("\n") + } + + /// Close stdin (signals EOF to the child) without killing. + pub async fn close_stdin(&self) { + if self.stdin.lock().await.take().is_some() { + debug!(pid = self.pid, "stdin closed"); + } + } + + /// Non-destructive interrupt: close stdin so a cooperative child sees EOF + /// and winds down on its own. Distinct from kill (which force-terminates). + pub async fn interrupt(&self) -> Result<(), ProcessError> { + self.close_stdin().await; + Ok(()) + } + + /// Graceful kill: close stdin, wait up to `grace` for self-exit, then + /// group-SIGKILL. Confirms exit before returning. + pub async fn kill(&self, grace: Duration) -> Result<(), ProcessError> { + self.close_stdin().await; + + let mut rx = self.exit_rx.clone(); + let exited = tokio::time::timeout(grace, async { + if rx.borrow().is_some() { + return; + } + let _ = rx.changed().await; + }) + .await; + if exited.is_ok() && self.exit_rx.borrow().is_some() { + debug!(pid = self.pid, "exited within grace"); + return Ok(()); + } + + warn!(pid = self.pid, "grace expired, force-killing subtree"); + // Unix: group-SIGKILL. Windows: terminate the whole Job (subtree), not + // the single process — the live handle holds the job (hot path). + #[cfg(unix)] + crate::force_kill(self.pid, self.process_group_id)?; + #[cfg(windows)] + if let Some(job) = &self.job { + job.terminate(); + } else { + crate::force_kill(self.pid, self.process_group_id)?; + } + + // Wait for the exit monitor to observe termination so callers don't + // race a still-live child after the kill returns. + let mut rx = self.exit_rx.clone(); + tokio::time::timeout(Duration::from_secs(5), async { + if rx.borrow().is_some() { + return; + } + let _ = rx.changed().await; + }) + .await + .map_err(|_| ProcessError::internal(format!("process {} did not exit after SIGKILL", self.pid)))?; + Ok(()) + } +} + +/// Validate a workspace cwd: non-empty, no whitespace segment (bundled runtime +/// can't handle it), exists, is a directory. +pub(crate) fn prepare_command_cwd(cwd: &str) -> Result { + if cwd.trim().is_empty() { + return Err(ProcessError::bad_request("workspace directory is empty")); + } + let path = PathBuf::from(cwd); + if path + .components() + .any(|c| c.as_os_str().to_string_lossy().contains(char::is_whitespace)) + { + return Err(ProcessError::workspace_path_contains_whitespace_runtime_unsupported( + path.display().to_string(), + )); + } + match std::fs::metadata(&path) { + Ok(m) if m.is_dir() => Ok(path), + Ok(_) => Err(ProcessError::bad_request(format!( + "workspace path is not a directory: {}", + path.display() + ))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(ProcessError::bad_request(format!( + "workspace directory does not exist: {}", + path.display() + ))), + Err(e) => Err(ProcessError::bad_request(format!( + "workspace directory not accessible: {}: {e}", + path.display() + ))), + } +} + +#[cfg(unix)] +pub(crate) fn tracked_process_group_id(pid: u32) -> Option { + Some(pid) // Builder sets process_group(0): the leader's pgid == its pid. +} + +#[cfg(not(unix))] +pub(crate) fn tracked_process_group_id(_pid: u32) -> Option { + None +} + +/// Windows Job Object subtree containment (feature 005 batch B). Transcribed +/// from watchexec/process-wrap's verified `src/windows.rs` scheme (Decision 2b: +/// borrow the SCHEME, not the crate). A `JobObject` owns a Job handle for the +/// child's whole lifetime: every descendant the child spawns is auto-trapped in +/// the job (Windows inherits job membership), so `terminate()` / +/// `KILL_ON_JOB_CLOSE` reaps the WHOLE subtree — the Windows analog of the Unix +/// process-group kill, and STRONGER (a `setsid`-style breakaway is not possible +/// without `JOB_OBJECT_LIMIT_BREAKAWAY_OK`). +#[cfg(windows)] +pub(crate) mod job_windows { + use std::os::windows::io::RawHandle; + + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, + }; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject, + TerminateJobObject, + }; + use windows_sys::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; + + /// An owned Job Object handle. RAII: dropping it closes the handle, which — + /// because we set `KILL_ON_JOB_CLOSE` — terminates every process still in the + /// job (the Windows kill-on-drop guarantee, even if our supervisor crashes). + pub(crate) struct JobObject { + job: HANDLE, + } + + // SAFETY: a Windows job HANDLE is just a kernel handle value; it is safe to + // move/share across threads (we only ever close it once, on Drop). + unsafe impl Send for JobObject {} + unsafe impl Sync for JobObject {} + + impl JobObject { + /// Create a job, set `KILL_ON_JOB_CLOSE`, assign the (suspended) child to + /// it, then resume the child's threads. The child MUST have been spawned + /// with `CREATE_SUSPENDED` (runtime's `configure_platform_spawn`) so it + /// has not yet run any instruction — closing the assign-vs-fork race. + /// + /// `child_raw` is tokio's `Child::raw_handle()` — we use it read-only for + /// `AssignProcessToJobObject` and NEVER close it (tokio owns it; a + /// double-close would be a use-after-free). `pid` is used only to find + /// the child's threads for resume (std/tokio expose no primary-thread + /// handle, hence the toolhelp walk). + pub(crate) fn assign_and_resume(child_raw: RawHandle, pid: u32) -> Result { + // SAFETY: CreateJobObjectW with null attrs/name returns a job handle + // or null on failure. + let job = unsafe { CreateJobObjectW(core::ptr::null(), core::ptr::null()) }; + if job.is_null() { + return Err(format!("CreateJobObjectW failed: {}", std::io::Error::last_os_error())); + } + let this = JobObject { job }; + + // Set KILL_ON_JOB_CLOSE so dropping the last job handle kills the + // whole subtree (crash-safety). + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { core::mem::zeroed() }; + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: info is a valid, fully-initialized struct of the given size. + let ok = unsafe { + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + core::ptr::from_ref(&info).cast(), + core::mem::size_of::() as u32, + ) + }; + if ok == 0 { + return Err(format!( + "SetInformationJobObject failed: {}", + std::io::Error::last_os_error() + )); + } + + // Assign the suspended child to the job. Descendants inherit it. + // SAFETY: child_raw is a live process handle owned by tokio; we only + // read it here. + let ok = unsafe { AssignProcessToJobObject(job, child_raw as HANDLE) }; + if ok == 0 { + return Err(format!( + "AssignProcessToJobObject failed: {}", + std::io::Error::last_os_error() + )); + } + + // Resume the child (it was spawned CREATE_SUSPENDED). std/tokio give + // no primary-thread handle, so enumerate the system thread table and + // resume every thread owned by our pid (process-wrap's documented + // "terrible hack" — the only option without reimplementing spawn). + resume_threads(pid).map_err(|e| format!("resume failed: {e}"))?; + + Ok(this) + } + + /// Synchronously terminate every process in the job (the whole subtree). + pub(crate) fn terminate(&self) { + // SAFETY: self.job is a valid job handle for our lifetime. + unsafe { TerminateJobObject(self.job, 1) }; + } + } + + impl Drop for JobObject { + fn drop(&mut self) { + // Closing the last job handle triggers KILL_ON_JOB_CLOSE → the whole + // subtree dies. This IS the Windows kill-on-drop guarantee. + // SAFETY: we own the handle and close it exactly once. + unsafe { CloseHandle(self.job) }; + } + } + + /// Resume all threads of `pid` via a system-wide toolhelp thread snapshot. + /// Edge cases (from process-wrap source): THREADENTRY32.dwSize MUST be the + /// struct size or Thread32First silently returns nothing; ResumeThread's + /// error sentinel is `u32::MAX` (not -1); the snapshot is SYSTEM-WIDE + /// (TH32CS_SNAPTHREAD ignores the pid arg) so we filter by th32OwnerProcessID. + fn resume_threads(pid: u32) -> Result<(), std::io::Error> { + // SAFETY: returns a snapshot handle or INVALID_HANDLE_VALUE. + let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if snap == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()); + } + let result = (|| { + let mut entry: THREADENTRY32 = unsafe { core::mem::zeroed() }; + entry.dwSize = core::mem::size_of::() as u32; + // SAFETY: entry is a valid, dwSize-initialized THREADENTRY32. + if unsafe { Thread32First(snap, &mut entry) } == 0 { + return Err(std::io::Error::last_os_error()); + } + loop { + if entry.th32OwnerProcessID == pid { + // SAFETY: open the thread for suspend/resume by tid. + let th = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) }; + if !th.is_null() { + // SAFETY: th is a valid thread handle; resume + close it. + let rc = unsafe { ResumeThread(th) }; + unsafe { CloseHandle(th) }; + if rc == u32::MAX { + return Err(std::io::Error::last_os_error()); + } + } + } + // SAFETY: entry stays valid; Thread32Next advances or signals end. + if unsafe { Thread32Next(snap, &mut entry) } == 0 { + break; // no more threads + } + } + Ok(()) + })(); + // SAFETY: close the snapshot handle we created. + unsafe { CloseHandle(snap) }; + result + } +} diff --git a/crates/aionui-process/src/registry_store.rs b/crates/aionui-process/src/registry_store.rs new file mode 100644 index 000000000..9e71a3cdd --- /dev/null +++ b/crates/aionui-process/src/registry_store.rs @@ -0,0 +1,378 @@ +//! Persisted registry of processes THIS crate spawned (IC-4). +//! +//! Lives in its own subdir `{data_dir}/runtime/aionui-process/registry.json`, +//! never touching the existing `agent-process-registry.json`. Written via a +//! durable atomic write whose temp file is namespaced to this crate + pid, so +//! it can never clobber another mechanism's temp. Accessed by exact path only +//! — never by directory scan/glob. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::ProcessError; + +/// Subdir + filenames, namespaced so they are provably disjoint from the +/// existing mechanism's artifacts and from bun's `runtime.lock` (IC-3/IC-4). +pub const SUBDIR: &str = "runtime/aionui-process"; +pub const REGISTRY_FILE: &str = "registry.json"; +pub const LOCK_FILE: &str = "instance.lock"; +/// Cross-process advisory lock for the registry read-modify-write (F49). +/// DELIBERATELY a SEPARATE file from `LOCK_FILE` (`instance.lock`): the +/// single-instance lock is held NON-BLOCKING for the whole process lifetime +/// (it gates reap), whereas this one is taken BLOCKING for the duration of a +/// single millisecond-scale RMW. Reusing the same file would self-deadlock — +/// the process already holds `instance.lock` exclusively for its whole life. +pub const REGISTRY_LOCK_FILE: &str = "registry.json.lock"; + +/// One process this crate spawned. Identity fields (`start_time_ticks` + +/// `instance_epoch`) back the IC-1 kill gate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegisteredProcess { + pub pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pgid: Option, + /// Kernel start-time in clock ticks (identity gate). None if unobtainable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_time_ticks: Option, + /// This-run UUID; a row whose epoch != current run is a prior-run orphan. + pub instance_epoch: Uuid, + /// Host identity; a row from another machine (cloud-synced data dir) is + /// prune-only, never killed (IC-1 cross-machine guard, design I-5). + pub machine_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub containment_id: Option, + /// Opaque owner tag (e.g. a conversation id); this layer never parses it. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub opaque_owner_tag: String, + pub registered_at_ms: i64, +} + +/// Identity key for `unregister` — by (pid, start_time, epoch), not bare pid, +/// so a recycled-pid row is never accidentally removed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProcessIdentity { + pub pid: u32, + pub start_time_ticks: Option, + pub instance_epoch: Uuid, +} + +impl RegisteredProcess { + fn identity(&self) -> ProcessIdentity { + ProcessIdentity { + pid: self.pid, + start_time_ticks: self.start_time_ticks, + instance_epoch: self.instance_epoch, + } + } +} + +/// The on-disk registry schema version this build writes and understands. +const CURRENT_REGISTRY_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RegistryFile { + /// `#[serde(default)]` so a registry missing the field (older/hand-edited) + /// is NOT a hard parse error that aborts the reap (F55 + ties into F39); + /// it defaults to the current version instead. + #[serde(default = "default_registry_version")] + version: u32, + processes: Vec, +} + +fn default_registry_version() -> u32 { + CURRENT_REGISTRY_VERSION +} + +impl Default for RegistryFile { + fn default() -> Self { + Self { + version: CURRENT_REGISTRY_VERSION, + processes: Vec::new(), + } + } +} + +/// Persisted store of this crate's spawned processes. +pub trait RegistryStore: Send + Sync { + fn record(&self, entry: RegisteredProcess) -> Result<(), ProcessError>; + /// Remove by full identity (recycled-pid rows with a different identity + /// are left intact). + fn unregister(&self, id: &ProcessIdentity) -> Result<(), ProcessError>; + /// The production reader — read all rows back (used by startup reap). + fn read_all(&self) -> Result, ProcessError>; +} + +/// File-backed registry under `{data_dir}/runtime/aionui-process/registry.json`. +pub struct FileRegistryStore { + path: PathBuf, + /// Sidecar path for the cross-process RMW lock (F49). See + /// [`REGISTRY_LOCK_FILE`]; a separate file from `instance.lock`. + lock_path: PathBuf, + /// Serializes read-modify-write within this process. `atomic_write` makes + /// only the final rename atomic; without this, two concurrent `record`s + /// in one process could lose a row (last-writer-wins) — leaking a spawned- + /// but-unrecorded, un-reapable orphan. Cross-process writers are serialized + /// by the additional fs lock in [`Self::with_rmw_flock`] (F49). + rmw: std::sync::Mutex<()>, +} + +impl FileRegistryStore { + pub fn new(data_dir: &Path) -> Self { + let dir = data_dir.join(SUBDIR); + let store = Self { + path: dir.join(REGISTRY_FILE), + lock_path: dir.join(REGISTRY_LOCK_FILE), + rmw: std::sync::Mutex::new(()), + }; + // F51: best-effort sweep of stray atomic-write temp files left by a + // crash/SIGKILL between fsync and rename. We scan ONLY this crate's own + // subdir and match ONLY our own exact temp prefix (`.registry.json.` + + // `.corrupt.`) — never a broad `*.tmp`/`*.json` glob (IC-4: never touch + // another mechanism's artifacts). Without this the design's + // "by-exact-path-only" rule means strays can NEVER be GC'd. + store.sweep_stray_temps(); + store + } + + /// Remove THIS PROCESS's own orphaned `.registry.json...tmp` + /// strays from a prior crash. PID-SCOPED (F51 review fix): the temp name is + /// `.{stem}.{pid}.{counter}.tmp`, so we only sweep temps carrying OUR pid — + /// never a SIBLING instance's in-flight atomic-write temp (deleting that + /// would make the sibling's `rename` fail NotFound and silently lose a row → + /// an un-reapable orphan). A stale temp from an OLD run that happens to share + /// our recycled pid is the only (harmless) over-match, and it is genuinely a + /// stray. Deliberately does NOT touch `.corrupt.` quarantine files (forensics, + /// F39). + fn sweep_stray_temps(&self) { + let Some(parent) = self.path.parent() else { return }; + let stem = self.path.file_name().and_then(|n| n.to_str()).unwrap_or(REGISTRY_FILE); + // Pid-scoped prefix: only our own process's temps. + let our_prefix = format!(".{stem}.{}.", std::process::id()); + let Ok(entries) = std::fs::read_dir(parent) else { return }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.starts_with(&our_prefix) && name.ends_with(".tmp") && !name.contains(".corrupt.") { + let _ = std::fs::remove_file(entry.path()); + } + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Read the registry, FAIL-SAFE on corruption (F39). A truncated / malformed + /// registry.json (e.g. an interrupted prior `atomic_write` that never reached + /// rename, or a stray byte) must NOT abort the whole reap — aborting would + /// leak every real orphan from the prior crash. Instead we QUARANTINE the bad + /// file (rename it aside for forensics, non-destructively) and degrade to an + /// empty registry: this run reaps nothing (safe — killing pids read from + /// corrupt data is strictly more dangerous than skipping a round), and the + /// next `write_file` starts clean (self-heal). A genuine I/O error (not a + /// parse error) still propagates — that is an environment fault, not data we + /// can safely ignore. + fn read_file(&self) -> Result { + let contents = match std::fs::read_to_string(&self.path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(RegistryFile::default()), + Err(e) => return Err(e.into()), + }; + match serde_json::from_str::(&contents) { + Ok(reg) if reg.version > CURRENT_REGISTRY_VERSION => { + // F55: a registry written by a FUTURE build may have renamed / + // repurposed fields. Feeding it into reconcile could group-kill on + // stale field meanings. Degrade to empty (reap nothing this round + // — safe) rather than trust forward-incompatible data. Do NOT + // quarantine: a newer sibling owns that file legitimately. + tracing::warn!( + path = %self.path.display(), + found = reg.version, + understood = CURRENT_REGISTRY_VERSION, + "registry schema version is newer than this build understands; degrading to empty (reap skipped)" + ); + Ok(RegistryFile::default()) + } + Ok(reg) if reg.version < CURRENT_REGISTRY_VERSION => { + // F55 (asymmetry made explicit, per review): a registry written + // by an OLDER build. This build understands all prior schemas by + // construction (fields are serde-default-tolerant + only additive + // changes are allowed across versions), so an older registry is + // SAFE to trust and reap from — unlike a newer one (above) whose + // field meanings we can't know. We log it for observability but + // proceed. If a future version ever makes a BREAKING change, this + // arm must change to a migration/degrade instead of blind trust. + tracing::debug!( + path = %self.path.display(), + found = reg.version, + understood = CURRENT_REGISTRY_VERSION, + "registry schema version is older than current; trusting (backward-compatible additive schema)" + ); + Ok(reg) + } + Ok(reg) => Ok(reg), + Err(e) => { + tracing::warn!( + path = %self.path.display(), + error = %e, + "registry is corrupt/unparseable; quarantining and degrading to empty (reap skipped this round)" + ); + self.quarantine_corrupt(); + Ok(RegistryFile::default()) + } + } + } + + /// Best-effort rename the corrupt registry aside so it is preserved for + /// forensics and a fresh one can be written. Namespaced to this crate's + /// subdir + pid/counter so it never collides; failure here is non-fatal + /// (we still degrade to empty). + fn quarantine_corrupt(&self) { + let stem = self + .path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("registry.json"); + if let Some(parent) = self.path.parent() { + let dst = parent.join(format!(".{stem}.corrupt.{}.{}", std::process::id(), next_counter())); + if let Err(e) = std::fs::rename(&self.path, &dst) { + tracing::warn!(error = %e, "failed to quarantine corrupt registry (will be overwritten on next write)"); + } + } + } + + fn write_file(&self, reg: &RegistryFile) -> Result<(), ProcessError> { + let bytes = + serde_json::to_vec_pretty(reg).map_err(|e| ProcessError::internal(format!("serialize registry: {e}")))?; + atomic_write(&self.path, &bytes) + } + + /// Run `f` while holding the CROSS-PROCESS registry lock (F49), so a + /// concurrent sibling instance cannot interleave its own read-modify-write + /// and lose a row (last-writer-wins). `atomic_write` only makes the final + /// rename atomic — it does NOT prevent two processes each reading the same + /// N rows, each appending one, and the second `rename` clobbering the + /// first's row. A BLOCKING `flock` around the whole RMW serializes that. + /// + /// Degrade-not-fail: if the lock file cannot be opened or locked (rare I/O + /// fault), we WARN and still run `f` — the cross-process guard is an + /// enhancement over the always-present in-process `rmw` Mutex; failing the + /// `record`/`unregister` outright would be a worse regression than the + /// single-instance behavior we had before F49. + fn with_rmw_flock(&self, f: impl FnOnce() -> Result) -> Result { + use fs2::FileExt; + if let Some(parent) = self.lock_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + // `File::create` sets O_CLOEXEC on unix by default, so this advisory-lock + // fd is NOT inherited by spawned children (same reasoning as + // instance_lock.rs) — an inherited lock fd could keep the lock "held" + // after we exit. + let lock_file = match std::fs::File::create(&self.lock_path) { + Ok(file) => file, + Err(e) => { + tracing::warn!( + path = %self.lock_path.display(), + error = %e, + "could not open registry lock file; proceeding without cross-process guard (F49 degraded)" + ); + return f(); + } + }; + // BLOCKING exclusive lock for the whole RMW (≠ instance.lock's + // non-blocking try_lock): we WANT to wait out a sibling's in-flight RMW, + // not bail. RMW is millisecond-scale so contention is brief. + if let Err(e) = lock_file.lock_exclusive() { + tracing::warn!( + path = %self.lock_path.display(), + error = %e, + "could not acquire registry lock; proceeding without cross-process guard (F49 degraded)" + ); + return f(); + } + let result = f(); + // Explicit unlock for deterministic release (closing the File would also + // release it, but on some platforms the close-triggered release can lag). + let _ = fs2::FileExt::unlock(&lock_file); + result + } +} + +impl RegistryStore for FileRegistryStore { + fn record(&self, entry: RegisteredProcess) -> Result<(), ProcessError> { + // Lock order (fixed, both RMW methods): in-process `rmw` Mutex (outer) + // → cross-process flock (inner). This crate takes both locks at exactly + // these two sites only, so there is no opposite-order acquisition and + // thus no cross-lock deadlock risk. + let _guard = self.rmw.lock().unwrap_or_else(|e| e.into_inner()); + self.with_rmw_flock(|| { + let mut reg = self.read_file()?; + reg.processes.retain(|p| p.identity() != entry.identity()); + reg.processes.push(entry); + self.write_file(®) + }) + } + + fn unregister(&self, id: &ProcessIdentity) -> Result<(), ProcessError> { + let _guard = self.rmw.lock().unwrap_or_else(|e| e.into_inner()); + self.with_rmw_flock(|| { + let mut reg = self.read_file()?; + let before = reg.processes.len(); + reg.processes.retain(|p| &p.identity() != id); + if reg.processes.len() == before { + return Ok(()); // nothing matched — idempotent + } + self.write_file(®) + }) + } + + fn read_all(&self) -> Result, ProcessError> { + Ok(self.read_file()?.processes) + } +} + +/// Durable atomic write into this crate's subdir. Temp file is namespaced to +/// the final path + pid + counter so it cannot collide with another +/// mechanism's temp or with concurrent writers (IC-4). Best-effort dir fsync. +/// `pub(crate)` so other durable artifacts (the machine-id, F40) reuse the same +/// temp+fsync+rename discipline instead of a torn `std::fs::write`. +pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ProcessError> { + use std::io::Write; + let parent = path + .parent() + .ok_or_else(|| ProcessError::internal("registry path has no parent"))?; + std::fs::create_dir_all(parent)?; + + let stem = path.file_name().and_then(|n| n.to_str()).unwrap_or("registry.json"); + let tmp = parent.join(format!(".{stem}.{}.{}.tmp", std::process::id(), next_counter())); + + { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(bytes)?; + f.flush()?; + f.sync_all()?; + } + if let Err(e) = std::fs::rename(&tmp, path).or_else(|e| { + if cfg!(windows) { + let _ = std::fs::remove_file(path); + std::fs::rename(&tmp, path) + } else { + Err(e) + } + }) { + let _ = std::fs::remove_file(&tmp); + return Err(e.into()); + } + if let Ok(dir) = std::fs::File::open(parent) { + let _ = dir.sync_all(); + } + Ok(()) +} + +fn next_counter() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static C: AtomicU64 = AtomicU64::new(0); + C.fetch_add(1, Ordering::Relaxed) +} diff --git a/crates/aionui-process/src/spawner.rs b/crates/aionui-process/src/spawner.rs new file mode 100644 index 000000000..29504d4b3 --- /dev/null +++ b/crates/aionui-process/src/spawner.rs @@ -0,0 +1,159 @@ +//! `Spawner` — the assembly entry that wires spawn → containment → registry. +//! +//! Record-once (design Decision 2): we spawn FIRST, get the real pid + identity, +//! then `record` a single complete row — no placeholder pid, no backfill window. +//! The crash window before `record` is acceptable: a process spawned but not yet +//! recorded is simply not reaped on the next run (it leaks at most one), which is +//! strictly safer than recording a placeholder we might group-kill. + +use std::path::PathBuf; +use std::sync::Arc; + +use aionui_common::CommandSpec; +use uuid::Uuid; + +use crate::registry_store::{ProcessIdentity, RegisteredProcess, RegistryStore}; +use crate::{ManagedProcess, ProcessError}; + +/// Spawns subprocesses and registers them so the supervisor can reap our own +/// orphans after a crash. +#[async_trait::async_trait] +pub trait Spawner: Send + Sync { + /// Spawn `spec`, place it under containment, and record it. `extra_env` is + /// applied per-child (IC-5). `opaque_owner_tag` is stored verbatim and + /// never parsed by this layer (it's the caller's conversation id etc). + async fn spawn( + &self, + spec: CommandSpec, + extra_env: &[(String, String)], + opaque_owner_tag: &str, + ) -> Result, ProcessError>; +} + +/// Production spawner backed by the file registry. Holds this run's identity +/// (epoch + machine_id) so every recorded row is identity-gateable on reap. +pub struct RealSpawner { + registry: Arc, + instance_epoch: Uuid, + machine_id: String, +} + +impl RealSpawner { + pub fn new(registry: Arc, instance_epoch: Uuid, machine_id: impl Into) -> Self { + Self { + registry, + instance_epoch, + machine_id: machine_id.into(), + } + } +} + +#[async_trait::async_trait] +impl Spawner for RealSpawner { + async fn spawn( + &self, + spec: CommandSpec, + extra_env: &[(String, String)], + opaque_owner_tag: &str, + ) -> Result, ProcessError> { + // Spawn first — the child is already its own process-group leader + // (Builder process_group(0)), so it is contained from birth. + let proc = ManagedProcess::spawn(spec, extra_env).await?; + let pid = proc.pid(); + let pgid = proc.process_group_id(); + + // Read start-time ONCE, while the child is freshly alive, and reuse it + // for BOTH the recorded row and the dereg identity below. Re-reading it + // at dereg time would race the process's exit (→ None / different value) + // and the identity would no longer match the recorded row, so dereg + // would silently fail (the bug the F38 test caught). + let start_time_ticks = crate::read_process_start_time(pid); + + // record-once with the real pid + identity (no placeholder). + let entry = RegisteredProcess { + pid, + pgid, + start_time_ticks, + instance_epoch: self.instance_epoch, + machine_id: self.machine_id.clone(), + // The pgid IS the containment id for the process-group tier. + containment_id: pgid.map(|g| g.to_string()), + opaque_owner_tag: opaque_owner_tag.to_owned(), + registered_at_ms: aionui_common::now_ms(), + }; + if let Err(e) = self.registry.record(entry) { + // If we cannot durably record it, we must not leave a live child we + // can never reap: tear it down before surfacing the error + // (no live-child-without-durable-row). Surface a kill failure rather + // than silently swallowing it (F59) — but still return the original + // record error (that is the root cause the caller must see). + if let Err(kill_err) = proc.kill(std::time::Duration::from_millis(200)).await { + tracing::warn!(pid, error = %kill_err, "record failed AND teardown of the un-recorded child failed"); + } + return Err(e); + } + + // Steady-state dereg (F38): when the handle is dropped (turn end / clean + // exit), remove this registry row so registry.json does not grow + // unbounded for the parent's whole lifetime. The hook is opaque to + // ManagedProcess and offloads the file I/O to a blocking pool so Drop + // never stalls the (tokio worker) thread that releases the last Arc. + let registry = Arc::clone(&self.registry); + let identity = ProcessIdentity { + pid, + start_time_ticks, // SAME value recorded above — must match for dereg + instance_epoch: self.instance_epoch, + }; + proc.set_on_drop(Box::new(move || { + // We may be inside a tokio runtime (consumer worker) or not (tests). + // If a runtime is present, offload; otherwise do it inline (cheap in + // tests / non-async drop sites). + match tokio::runtime::Handle::try_current() { + Ok(handle) => { + handle.spawn_blocking(move || { + if let Err(e) = registry.unregister(&identity) { + tracing::warn!(error = %e, "failed to deregister process from registry on drop"); + } + }); + } + Err(_) => { + if let Err(e) = registry.unregister(&identity) { + tracing::warn!(error = %e, "failed to deregister process from registry on drop"); + } + } + } + })); + + Ok(Arc::new(proc)) + } +} + +/// A stable, NON-synced machine identifier for the cross-machine reap guard +/// (IC-5). Persisted in the OS-LOCAL cache dir (NOT `data_dir`, which may be +/// iCloud/Dropbox-synced — a synced id would defeat the guard). First call +/// mints + persists a random UUID; later calls read it back. +pub fn local_machine_id(os_local_cache_dir: &std::path::Path) -> String { + let path: PathBuf = os_local_cache_dir.join("aionui-process").join("machine-id"); + if let Ok(s) = std::fs::read_to_string(&path) { + let t = s.trim(); + if !t.is_empty() { + return t.to_owned(); + } + // Non-empty file existed but was blank/whitespace → a prior torn write. + // Re-minting here orphans every prior-machine registry row as foreign, + // silently disabling reaping (F40). Warn loudly so this is observable. + tracing::warn!( + path = %path.display(), + "machine-id file present but empty (likely a torn write); minting a NEW id — \ + prior registry rows from the old id will be treated as foreign and only pruned" + ); + } + let id = Uuid::new_v4().to_string(); + // Durable atomic write (F40): temp + fsync + rename, so a kill/disk-full + // mid-write can never leave the empty file that triggers the silent re-mint + // above. A bare `std::fs::write` is NOT atomic and was the root cause. + if let Err(e) = crate::registry_store::atomic_write(&path, id.as_bytes()) { + tracing::warn!(error = %e, "failed to durably persist machine-id; reap identity may not be stable across runs"); + } + id +} diff --git a/crates/aionui-process/src/supervisor/core.rs b/crates/aionui-process/src/supervisor/core.rs new file mode 100644 index 000000000..c93fbe3f7 --- /dev/null +++ b/crates/aionui-process/src/supervisor/core.rs @@ -0,0 +1,124 @@ +//! Pure reconcile kernel — the reap decision brain (Tier-A exhaustible). +//! +//! No `.await`, no clock, no syscall: it takes an already-gathered +//! [`ObservedState`] and emits [`Action`]s. Every IC-1 safety rule lives here +//! as a pure predicate, so the dangerous "what do we kill" logic is unit-test +//! exhaustible and mutation-guardable. +//! +//! The whole point: a reap is emitted ONLY for a registry row that is a +//! prior-run orphan, on THIS machine, whose live process identity MATCHES what +//! we recorded — and only when we hold the single-instance lock. Everything +//! else prunes (removes the stale row) without killing. + +use uuid::Uuid; + +use crate::Liveness; +use crate::registry_store::RegisteredProcess; + +/// Whether the single-instance lock is held this run. Reaping is gated on it: +/// if a sibling instance holds it, we must NOT kill (its processes are live). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LockState { + Acquired, + HeldBySibling, +} + +/// Everything the kernel needs, gathered impurely by the caller. +pub struct ObservedState { + /// Registry rows read back from disk (this crate's own registry). + pub rows: Vec, + /// Liveness/identity classification per row pid (from proc_control::probe + /// + classify_liveness), keyed by index into `rows`. + pub liveness: Vec, + pub lock_state: LockState, + pub current_epoch: Uuid, + pub current_machine: String, +} + +/// A decision the runner must execute. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Group-SIGKILL a confirmed prior-run orphan, then prune its row. + /// `recorded_start_ticks` is carried so the runner can RE-VERIFY identity + /// immediately before the kill (F42 TOCTOU close): between `gather_observed` + /// (which classified this as Match) and the actual `force_kill`, the pid + /// could exit and be recycled. The runner re-probes and only kills if the + /// live start-time still matches this recorded value. + ReapOrphanGroup { + pid: u32, + pgid: Option, + recorded_start_ticks: Option, + }, + /// Kill the containment fence of a confirmed orphan (covers grandchildren). + ReapContainment { containment_id: String }, + /// Remove a stale/unkillable/foreign row without killing anything. + PruneRegistryEntry { pid: u32 }, +} + +/// The pure reap decision. For each row, decide reap-and-prune vs prune-only. +/// +/// Safety rules enforced here (IC-1): +/// - Lock not Acquired => emit NOTHING (never touch a live sibling's procs). +/// - Foreign machine_id => prune-only (cloud-synced data dir, design I-5). +/// - Own-epoch row => never a reap target (it's one of THIS run's, I-4). +/// - Liveness != Match => prune-only (recycled/unknown/eperm/gone, I-1). +/// - Only Match + prior-epoch + same-machine => ReapOrphanGroup (+ContainmentReap). +pub fn reconcile(observed: &ObservedState) -> Vec { + reconcile_with_capability(observed, crate::Capabilities::current().can_kill) +} + +/// Capability-gated reconcile (F58). `can_kill` is normally +/// `Capabilities::current().can_kill`; the seam exists so the "unknown platform +/// must never emit a kill it cannot perform" invariant is ENFORCED (not merely +/// emergent from `probe`→Unknown), and is unit-testable by passing `false`. +pub fn reconcile_with_capability(observed: &ObservedState, can_kill: bool) -> Vec { + // Gate 1: no lock => do not reap anything (IC-1 / I-3 lock-gate). + if observed.lock_state != LockState::Acquired { + return Vec::new(); + } + + let mut actions = Vec::new(); + for (row, &live) in observed.rows.iter().zip(observed.liveness.iter()) { + // Gate 2: foreign machine => prune-only (I-5). A pgid on machine B is + // meaningless/dangerous here. + if row.machine_id != observed.current_machine { + actions.push(Action::PruneRegistryEntry { pid: row.pid }); + continue; + } + // Gate 3: identity. Only an identity-MATCHED, prior-epoch, live process + // is a reap target. classify_liveness already encodes "Match == alive + // && start-time matches && prior epoch"; DiffEpoch == our own run. + match live { + // Gate 0 (F58): only emit a kill if the platform can actually + // perform it. On a platform without a real `force_kill`, emitting + // ReapOrphanGroup would warn-and-swallow while the row is pruned and + // the live process forgotten — prune-and-forget. If we cannot kill, + // prune-only (safe), enforced here rather than relying on probe + // happening to return Unknown. + Liveness::Match if can_kill => { + // Reap the containment first (covers grandchildren), then the + // group, then prune. The runner executes in order. + if let Some(cid) = &row.containment_id { + actions.push(Action::ReapContainment { + containment_id: cid.clone(), + }); + } + actions.push(Action::ReapOrphanGroup { + pid: row.pid, + pgid: row.pgid, + recorded_start_ticks: row.start_time_ticks, + }); + actions.push(Action::PruneRegistryEntry { pid: row.pid }); + } + // Match but platform cannot kill → prune-only (F58 safe degrade). + Liveness::Match => { + actions.push(Action::PruneRegistryEntry { pid: row.pid }); + } + // Everything else: prune the stale row, never kill (I-1 negative). + Liveness::RecycledPid | Liveness::DiffEpoch | Liveness::Gone | Liveness::Unknown | Liveness::EpermAlive => { + actions.push(Action::PruneRegistryEntry { pid: row.pid }); + } + } + } + actions +} diff --git a/crates/aionui-process/src/supervisor/mod.rs b/crates/aionui-process/src/supervisor/mod.rs new file mode 100644 index 000000000..d3e5222d0 --- /dev/null +++ b/crates/aionui-process/src/supervisor/mod.rs @@ -0,0 +1,143 @@ +//! Supervisor — the runner around the pure [`core::reconcile`] kernel. +//! +//! Gathers `ObservedState` (read registry + probe each row's live identity + +//! lock state), calls the pure kernel, and executes the returned `Action`s +//! (group-kill + container-kill + prune). This is the impure shell; all the +//! dangerous decisions live in the pure kernel so they stay exhaustible. + +mod core; + +pub use core::{Action, LockState, ObservedState, reconcile, reconcile_with_capability}; + +use uuid::Uuid; + +use crate::Liveness; +use crate::proc_control::{self, ObservedLiveness, current_process_group}; +use crate::registry_store::{ProcessIdentity, RegistryStore}; + +/// Gather the observed state for a startup reap: read every registry row and +/// probe its live identity against the recorded start-time + epoch. +pub fn gather_observed( + registry: &S, + lock_state: LockState, + current_epoch: Uuid, + current_machine: &str, +) -> Result { + let rows = registry.read_all()?; + let liveness = rows + .iter() + .map(|r| { + let observed: ObservedLiveness = proc_control::probe(r.pid); + proc_control::classify_liveness(r.start_time_ticks, observed, r.instance_epoch, current_epoch) + }) + .collect::>(); + Ok(ObservedState { + rows, + liveness, + lock_state, + current_epoch, + current_machine: current_machine.to_owned(), + }) +} + +/// Execute the reconcile actions: kill confirmed orphans, prune stale rows. +/// Best-effort and warn-logged — a single failure never aborts the rest. +pub fn execute_actions(actions: &[Action], registry: &S) { + for action in actions { + match action { + Action::ReapOrphanGroup { + pid, + pgid, + recorded_start_ticks, + } => { + // F42: RE-VERIFY identity immediately before the kill. reconcile + // classified this row as Match using a start-time read back in + // gather_observed; the pid could have exited and been recycled + // onto an innocent process in the window. Re-probe NOW and only + // kill if the live start-time still matches what we recorded. + let observed = proc_control::probe(*pid); + let still_ours = matches!( + observed, + ObservedLiveness::Alive { start_ticks } if start_ticks == *recorded_start_ticks && start_ticks.is_some() + ); + // Self-group guard (critic category): never group-kill a pgid + // the REAPER itself belongs to — that would SIGKILL ourselves. + let our_pgid = current_process_group(); + let targets_own_group = matches!((*pgid, our_pgid), (Some(p), Some(o)) if p == o); + if !still_ours { + tracing::warn!( + pid, + "skipping reap: identity changed between gather and kill (pid exited/recycled) — never kill on doubt (F42)" + ); + } else if targets_own_group { + tracing::error!( + pid, + ?pgid, + "refusing to reap: target pgid is the reaper's OWN process group (would self-SIGKILL)" + ); + } else if let Err(e) = proc_control::force_kill(*pid, *pgid) { + tracing::warn!(pid, ?pgid, error = %e, "reap orphan group failed"); + } else { + // Post-reap verification (critic category): SIGKILL is async; + // confirm the group is actually gone rather than asserting + // success. A still-alive group after the kill = an escaped + // (setsid) grandchild — log it honestly (DegradedBestEffort), + // do not pretend it was fully reaped. + if proc_control::process_group_alive(*pgid) { + tracing::warn!( + pid, + ?pgid, + "reap issued but group still alive (likely a setsid-escaped grandchild) — degraded, not confirmed gone" + ); + } + } + } + Action::ReapContainment { containment_id } => { + // Containment teardown for grandchildren is owned by the + // caller that created it; here we only have the id recorded. + // The group-kill above already covers same-group descendants; + // a future cgroup/JobObject tier would act on this id. + tracing::debug!( + containment_id, + "reap containment (process-group tier: covered by group kill)" + ); + } + Action::PruneRegistryEntry { pid } => { + // Prune by pid is sufficient here: the row is stale/foreign/ + // dead. We remove every identity sharing this pid in our own + // registry (there can only be our own rows). + if let Err(e) = prune_pid(registry, *pid) { + tracing::warn!(pid, error = %e, "prune registry entry failed"); + } + } + } + } +} + +fn prune_pid(registry: &S, pid: u32) -> Result<(), crate::ProcessError> { + // Find the row(s) with this pid and unregister by full identity. + for r in registry.read_all()? { + if r.pid == pid { + registry.unregister(&ProcessIdentity { + pid: r.pid, + start_time_ticks: r.start_time_ticks, + instance_epoch: r.instance_epoch, + })?; + } + } + Ok(()) +} + +/// One-shot startup reap: gather → reconcile → execute. Returns the actions +/// taken (for logging/testing). +pub fn run_startup_reap( + registry: &S, + lock_state: LockState, + current_epoch: Uuid, + current_machine: &str, +) -> Result, crate::ProcessError> { + let observed = gather_observed(registry, lock_state, current_epoch, current_machine)?; + let actions = reconcile(&observed); + execute_actions(&actions, registry); + Ok(actions) +} diff --git a/crates/aionui-session/Cargo.toml b/crates/aionui-session/Cargo.toml new file mode 100644 index 000000000..397c44672 --- /dev/null +++ b/crates/aionui-session/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "aionui-session" +version.workspace = true +edition.workspace = true + +# Feature 002: server-authoritative SessionState control plane. Domain layer. +# Self-contained: depends ONLY on aionui-process (001) + aionui-common. Spawns +# claude CLI directly (no ACP/SDK), frames+parses stream-json, synthesizes a +# 5-state FSM whose unlock signal is a STATE TRANSITION (decoupled from any +# blocking call return). NO production caller this iteration (same discipline +# as 001) — built and verified in isolation; UI-unlock wiring is feature 003+. +[dependencies] +aionui-common.workspace = true +aionui-process.workspace = true +async-trait.workspace = true +base64.workspace = true +futures-util = { workspace = true } +serde = { workspace = true } +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +tokio = { workspace = true } +# claude's `--session-id`/`--resume` require a bare valid UUID; the seam mints +# one when the logical id is not a UUID (claude_conn::claude_session_id_for). +uuid = { workspace = true, features = ["v4"] } + +[features] +# Exposes the in-crate test doubles (FakeAgentIo / FakeSpawner) to integration +# tests and downstream verification. Gated so production builds never ship them. +test-support = [] + +[dev-dependencies] +# Self-reference with test-support so the integration tests (tests/*.rs, separate +# compilation units) see `testing::{FakeAgentIo, invariants}` + the backends' +# `build_with_io` — all gated behind `cfg(any(test, feature = "test-support"))`, +# which the bare integration build does NOT enable on its own. +aionui-session = { path = ".", features = ["test-support"] } +tokio = { workspace = true } +proptest.workspace = true +# live_cli_e2e.rs (#[ignore] LIVE tests): a tempdir-backed FileRegistryStore for +# constructing a production RealSpawner without touching the real data dir. +# Workspace-pinned; dev-only (never shipped). (uuid is a normal dep above.) +tempfile.workspace = true diff --git a/crates/aionui-session/proptest-regressions/adapter/claude.txt b/crates/aionui-session/proptest-regressions/adapter/claude.txt new file mode 100644 index 000000000..7dcd5179c --- /dev/null +++ b/crates/aionui-session/proptest-regressions/adapter/claude.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc c73a9d78a5dc5a02b4c400c0b02e8996b4b960d351d4b641efe226848b494c17 # shrinks to is_err = None, result = None, errors = [] +cc 49bb88320fe0e89fe70efeb2a4b789de0a926a7e863757f99ab187e5ddc57c00 # shrinks to blocks = [(None, "")] diff --git a/crates/aionui-session/src/adapter.rs b/crates/aionui-session/src/adapter.rs new file mode 100644 index 000000000..bb1e9b306 --- /dev/null +++ b/crates/aionui-session/src/adapter.rs @@ -0,0 +1,229 @@ +//! The backend seam (§C 6.4, frozen): `AgentIo` (narrow process-I/O) + +//! `BackendAdapter` (the "how to add other agents" entry: 3 responsibilities). +//! +//! Isolation (I9): a `BackendAdapter` can ONLY produce canonical `SessionEvent` +//! — its method signatures reference NO `SessionState`/reducer/oracle. This is +//! stronger than a base class: a backend physically cannot corrupt the FSM. +//! Adding a backend = a new impl; reducer/FSM/oracle change 0 lines. + +use std::sync::Arc; + +use aionui_process::{BoxedStdin, BoxedStdout, ManagedProcess, ProcessError, Spawner}; + +use crate::capability::Capabilities; +use crate::event::{ExitStatusLite, SessionEvent}; + +mod claude; +pub(crate) use claude::claude_permission_modes; +pub use claude::{ClaudeAdapter, is_valid_claude_permission_mode}; + +/// How a persistent session process should be started (feature 004 R16/D12/S17). +/// Backend-agnostic: the adapter translates it into backend flags (claude: +/// `--session-id ` vs `--resume `). Live-probed (claude 2.1.168): +/// reusing `--session-id` for an existing session id hard-errors +/// (`already in use`); continuation is ONLY via `--resume`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionSpec { + /// Start a brand-new session with this freshly-minted id (first turn, or + /// no persisted id). claude: `--session-id `. + Fresh(String), + /// Resume an existing session by its persisted id (crash / idle-reap + /// respawn). claude: `--resume `. On a missing/corrupt id the process + /// 0-frame-exits (`No conversation found`); the manager detects that and + /// falls back to `Fresh` (R16/S17). + Resume(String), +} + +/// Narrow process-I/O seam (D2). Exposes only the two methods P0 uses: the +/// byte-duplex handoff and the exit watch. Both async (mirrors real 001 +/// `process.rs`). A thin impl wraps `Arc`; an in-crate +/// duplex-backed fake replays fixtures. kill/interrupt are P1 (R12), out of +/// this trait. +#[async_trait::async_trait] +pub trait AgentIo: Send + Sync { + /// Take the byte-duplex once (mirrors 001 `take_stdio`: second call returns + /// `None`). Fits D6 one-shot (one spawn / one take per turn). + async fn take_stdio(&self) -> Option<(BoxedStdin, BoxedStdout)>; + + /// Watch for process exit (the R7 listen point). `Some(status)` = exited + /// with a known status; `None` = exited with unknown status (wait errored / + /// process gone) → MUST be treated as a terminal exit (D3), NOT "still + /// running". Resolving at all means it has exited (001 semantics). + async fn wait_for_exit(&self) -> Option; + + /// Peek the last few lines of buffered stderr for diagnostics (S19): on a + /// 0-frame / startup crash the real cause ("unknown option …", "Invalid MCP + /// configuration", "No conversation found") is only on stderr. Default + /// returns empty so fakes / non-process backends need not implement it; the + /// real `ManagedProcessIo` delegates to 001 `peek_stderr_tail`. + async fn peek_stderr(&self, _max_lines: usize) -> String { + String::new() + } +} + +/// How many trailing stderr lines G2 peeks when a backend process exits. The +/// real cause line (usage/rate limit, auth, network) is almost always among the +/// last few; a small window keeps the redaction cheap and bounds exposure. +pub(crate) const STDERR_PEEK_LINES: usize = 32; + +/// G2: capture a redacted, allowlisted one-line summary of a backend process's +/// stderr tail for the terminal `Detached` event. Shared by every backend's +/// reader task (and the `run_turn` core) so redaction happens in exactly one +/// place — raw/untrusted stderr never crosses the backend boundary. Returns +/// `None` when nothing matches the `aionui_common::error_extract` allowlist +/// (caller keeps a bare exit description). Call only on a REAL exit/EOF (where +/// stderr has had a chance to fill); the startup double-take guards pass `None`. +pub(crate) async fn redact_exit_stderr(io: &dyn AgentIo) -> Option { + let tail = io.peek_stderr(STDERR_PEEK_LINES).await; + aionui_common::error_extract::extract_error_message(&tail) +} + +/// Thin `AgentIo` over an `Arc` (the real 001 handle). Converts +/// the std `ExitStatus` into the contract's `ExitStatusLite`. +pub struct ManagedProcessIo { + proc: Arc, +} + +impl ManagedProcessIo { + pub fn new(proc: Arc) -> Self { + Self { proc } + } +} + +#[async_trait::async_trait] +impl AgentIo for ManagedProcessIo { + async fn take_stdio(&self) -> Option<(BoxedStdin, BoxedStdout)> { + self.proc.take_stdio().await + } + + async fn wait_for_exit(&self) -> Option { + self.proc.wait_for_exit().await.map(exit_status_to_lite) + } + + async fn peek_stderr(&self, max_lines: usize) -> String { + self.proc.peek_stderr_tail(max_lines).await + } +} + +/// Convert a std `ExitStatus` into the trimmed contract type. +fn exit_status_to_lite(status: std::process::ExitStatus) -> ExitStatusLite { + #[cfg(unix)] + let signal = { + use std::os::unix::process::ExitStatusExt; + status.signal() + }; + #[cfg(not(unix))] + let signal = None; + + ExitStatusLite { + code: status.code(), + signal, + } +} + +/// The backend protocol seam (D1/D10) — the ONLY entry for "how to add other +/// agents". Thin polymorphic adapter; the reducer/FSM are monomorphic and NOT +/// in this trait. P0's only impl = `ClaudeAdapter`; Codex/Gemini/OpenAI have no +/// real fixtures (Non-Goal). Instance lifecycle = one per turn (D6): the +/// parse half-line buffer MUST be empty at `start_turn` (no cross-turn leak). +/// +/// Forward-pointer (trait-widening, pre-second-backend, compendium §5.2): P0 is +/// 3 responsibilities — correct + frozen for claude `--print` stdio (a pure +/// forward pipe, no inbound reverse-RPC). ACP-dialect backends widen the +/// written contract to ~5 (serve fs/terminal/elicitation reverse-RPC + session +/// lifecycle verbs); those never reach the reducer. See RFC §8. +#[async_trait::async_trait] +pub trait BackendAdapter: Send + Sync { + /// Responsibility (1) STARTUP: build the `CommandSpec`, spawn via the + /// INJECTED `Spawner`. MUST spawn via the injected spawner (S14, MUST NOT + /// raw-spawn / hardcode RealSpawner|bare). Returns the `AgentIo` for this + /// session; the impl type is not named in the public contract. + /// + /// Feature 004 (R16/D12/S17): persistent multi-turn. `start_turn` only + /// SPAWNS the persistent process (per `SessionSpec`: fresh vs resume); the + /// prompt is NOT a spawn argument. Each turn's user message is delivered via + /// `deliver_prompt` over the retained stdin — including the FIRST turn. The + /// `system`-prompt / `mcp-config` injection (S18) is the manager's concern, + /// passed in `extra_args` (kept backend-neutral here). + /// + /// `cwd` is the conversation workspace — it MUST be set so the process runs + /// (and its file tools operate) in the conversation's directory, AND because + /// claude keys its on-disk session by cwd: a `--resume` only succeeds when + /// the process runs with the SAME cwd the session was created under + /// (verified: a cwd mismatch yields "No conversation found"). `None` = + /// inherit the parent cwd (only for tests / cwd-agnostic backends). + /// `env` (#103): extra environment variables injected into the spawned + /// process's `CommandSpec.env` (on top of the inherited parent env). Empty = + /// inherit parent env only. The claude direct-CLI path uses this to carry the + /// cc-switch provider env; the adapter only forwards it, never sources it. + async fn start_turn( + &self, + spawner: &dyn Spawner, + session: &SessionSpec, + cwd: Option<&str>, + extra_args: &[String], + env: &[aionui_common::EnvVar], + ) -> Result, ProcessError>; + + /// Responsibility (1b) PROMPT DELIVERY: write ONE user turn to the retained + /// stdin as a single NDJSON line + flush (NOT close — the process stays + /// alive for the next turn). Feature 004 S16: the line MUST be built with + /// `serde_json` over a typed value (NEVER `format!`/interpolation) so any + /// `\n`/quote/unicode in any text is escaped into exactly one frame. + /// + /// `content` is the canonical multimodal block slice; each adapter maps it to + /// its own wire. The sole claude impl maps `Text → {type:text}`, `Image → + /// the native base64 image block` (so the model sees it), and `ResourceLink → + /// a `[Attached file: ]` text element` (so claude's Read tool fetches it + /// from cwd — headless claude has no working document/resource input block). + /// Blocks a backend does not advertise (`Capabilities::prompt_blocks`) are + /// rejected at dispatch BEFORE this is called, so unreachable variants are a + /// defensive no-op here. + /// + /// `client_msg_id` is our per-message correlation id (the conversation layer's + /// `client_msg_id`). When `Some`, an adapter MAY stamp it on the wire frame so + /// the backend echoes it back for attribution — the claude impl writes it as the + /// user frame's `uuid`, which claude replays verbatim under `--replay-user-messages` + /// (see protocols/design/claude-midturn-input-turn-gen-design.md §3.3). Adapters + /// that have no such echo channel ignore it. `None` ⇒ no correlation requested. + async fn deliver_prompt( + &self, + stdin: &mut BoxedStdin, + content: &[crate::ContentBlock], + client_msg_id: Option<&str>, + ) -> Result<(), ProcessError>; + + /// Responsibility (1c) CONTROL RESPONSE (feature 004 F3): write ONE + /// already-serialized `control_response` JSON value to the retained stdin as + /// a single NDJSON line + flush (NOT close). This answers a `control_request` + /// (e.g. `can_use_tool` / AskUserQuestion) the agent emitted and is BLOCKING + /// on. The reducer/FSM never sees this; it is a pure transport write, kept on + /// the adapter so the stdin framing (serde, single `\n`, flush-not-close) + /// lives in one place. `value` is the full `{"type":"control_response",...}` + /// object the caller built per the backend's wire contract. + async fn write_control_response( + &self, + stdin: &mut BoxedStdin, + value: &serde_json::Value, + ) -> Result<(), ProcessError>; + + /// Responsibility (2) FRAMING + PARSE: raw bytes → canonical `SessionEvent` + /// sequence. `&mut self`: maintains a half-line buffer across calls + /// (stateful, S3). The buffer persists ACROSS turns/result spans (the + /// persistent process's stdout does not EOF between turns) — it is NOT + /// reset per turn. Unknown frames → `AdapterSpecific` catch-all, never + /// panics (S3/I4). claude tokens are normalized here (I8). + /// + /// DUP-10: the sole trait-object caller was the deleted `session::run_turn`. + /// `ClaudeConnection` now drives the CONCRETE `ClaudeAdapter::parse_chunk` + /// (inherent dispatch) + parity tests still exercise it, so the trait method + /// is dead only as a vtable entry. Kept on the trait pending the + /// `BackendAdapter` teardown (F7); `#[allow]` so `-D warnings` stays green. + #[allow(dead_code)] + fn parse_chunk(&mut self, bytes: &[u8]) -> Vec; + + /// Responsibility (3) CAPABILITY: tier + the canonical signals it can emit + /// (RFC §8 min-capability degradation). Const / pure. Read only by the + /// adapter, NEVER by the pure reducer `step()` (I12). + fn capabilities(&self) -> Capabilities; +} diff --git a/crates/aionui-session/src/adapter/claude.rs b/crates/aionui-session/src/adapter/claude.rs new file mode 100644 index 000000000..36429cdde --- /dev/null +++ b/crates/aionui-session/src/adapter/claude.rs @@ -0,0 +1,2218 @@ +//! `ClaudeAdapter` — the ONLY claude-aware code (seam a-side). Spawns the +//! claude CLI headless, frames + parses its stream-json (NDJSON) into the +//! canonical `SessionEvent` vocabulary, and declares its capabilities. +//! +//! Invariant I8: every claude-specific token (system/api_retry, compact_*, +//! subtype, content-block "type" strings) is normalized HERE; none leaks past +//! the canonical `SessionEvent` into the reducer. + +use aionui_common::CommandSpec; +use aionui_process::{BoxedStdin, ProcessError, Spawner}; +use serde_json::{Value, json}; +use tokio::io::AsyncWriteExt; + +use super::{AgentIo, BackendAdapter, ManagedProcessIo, SessionSpec}; +use crate::capability::{Capabilities, CapabilityTier, ModeInfo, SignalSet}; +use crate::event::SessionEvent; + +/// The claude backend adapter. Feature 004: ONE instance per persistent +/// SESSION (not per turn). `buf` (the NDJSON half-line buffer) persists across +/// turns/result spans — the persistent process's stdout does not EOF between +/// turns, so the buffer is drained line-by-line continuously and is NOT reset +/// per turn. +#[derive(Default)] +pub struct ClaudeAdapter { + /// Half-line buffer: bytes received since the last newline (S3). Spans + /// result boundaries (persistent multi-turn). + buf: Vec, + /// P3 partial streaming: per-assistant-message state so `content_block_delta` + /// text fragments emit as incremental `MessageDelta`s (typewriter) and the + /// consolidated `assistant` frame skips re-emitting text it already streamed. + stream: StreamState, +} + +/// Per-message streaming state for `--include-partial-messages`. Reset on each +/// `message_start` (the per-assistant-message fence; a turn may hold several +/// across tool rounds, each with a fresh `message.id`), so it is naturally +/// bounded and never grows across a session. +#[derive(Default)] +struct StreamState { + /// `message.id` of the in-flight assistant message — the stem of the per-kind + /// delta item_id (`:text` / `:think`). Empty before the first + /// `message_start` (degrades to `:text`/`:think`, still kind-separated). + item_id: String, + /// content-block `index` → kind, captured at `content_block_start`, used to + /// route each `content_block_delta` (the delta frames carry only `index`). + block_kind: std::collections::HashMap, + /// Whether live text was streamed for THIS message — the consolidated + /// `assistant` text block is then suppressed (the finalizer APPENDS, so + /// re-emitting would double the persisted reply). Reset per `message_start`. + streamed_text: bool, + /// Symmetric guard for thinking. NB: Bedrock never emits `thinking_delta` + /// (LIVE-probed) so this stays false there and thinking falls back to the + /// consolidated frame (empty on Bedrock); direct-API backends may stream it. + streamed_thinking: bool, +} + +#[derive(Clone, Copy, PartialEq)] +enum StreamBlockKind { + Text, + Thinking, + Other, +} + +/// The EXACT wire values claude's `--permission-mode` / `set_permission_mode` +/// accept — the SINGLE source for the seed-time whitelist +/// ([`is_valid_claude_permission_mode`]). Feeding claude anything outside this set +/// makes the spawn fail (exit 1), so any value sourced from unconstrained storage +/// (e.g. an assistant default `permission_value`, a free-text TEXT column) MUST be +/// validated against this before it reaches a `--permission-mode` flag. +/// +/// These are the 6 canonical `PermissionMode` values the bundled SDK +/// (`@anthropic-ai/claude-agent-sdk` 0.3.156, `sdk.d.ts` `PermissionMode`) and the +/// CLI both accept: `default | acceptEdits | bypassPermissions | plan | dontAsk | +/// auto`. NOTE this whitelist is a SUPERSET of the advertised picker +/// ([`claude_permission_modes`], which omits `auto` — see there): validation must +/// accept `auto` because a session resumed/persisted from the legacy ACP path (which +/// DID advertise `auto` when the model reported `supportsAutoMode`) may carry +/// `current_mode = "auto"`, and downgrading/rejecting it would crash the spawn. +/// The CLI-only alias `manual` is deliberately excluded — we never emit it. +const CLAUDE_PERMISSION_MODE_IDS: [&str; 6] = + ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"]; + +/// True iff `mode` is one of claude's exact accepted permission-mode wire values. +/// Use this to guard any seed/persist of `current_mode_id` for a claude session +/// when the source value domain is not already constrained — an invalid value +/// would crash the next spawn (`build_claude_init_args` passes it verbatim to +/// `--permission-mode`). +pub fn is_valid_claude_permission_mode(mode: &str) -> bool { + CLAUDE_PERMISSION_MODE_IDS.contains(&mode) +} + +/// claude's permission-mode picker, advertised as `available_modes` so the UI mode +/// picker has data (the write path — `--permission-mode` / +/// control_request{set_permission_mode} — was already wired). The `id`s are the +/// EXACT wire values claude accepts; name/description are display copy. +/// +/// VERBATIM-EQUIVALENT to the legacy ACP bridge's `buildAvailableModes(modelInfo)` +/// (`@agentclientprotocol/claude-agent-acp` 0.39.0, `acp-agent.js`): same ids, same +/// names, same descriptions, same order — so the frontend (which renders whatever +/// `available_modes` the backend sends, i18n-keyed on `agentMode.`) receives +/// byte-identical data and needs zero change (the claude arm of feature-012 "Plan B"). +/// +/// Two of the bridge's six modes are conditionally advertised; we reproduce the +/// condition, not just the mode list: +/// +/// - `auto` — bridge gates it on `modelInfo?.supportsAutoMode === true` (recomputed +/// on model switch). The direct-CLI `initialize`/`system.init` response carries NO +/// `supportsAutoMode` for ANY model (live-verified across default/sonnet/opus/ +/// haiku/fable-5; SDK 0.3.156 passes CLI models through without synthesizing the +/// field), and [`ModelInfo`] has no such field to thread. So legacy-exact behavior +/// under the current CLI = `auto` is NEVER advertised. We OMIT it here rather than +/// emit dead structure. `is_valid_claude_permission_mode` still ACCEPTS `auto` +/// (superset) so a session that carries it (legacy-persisted / future CLI) does not +/// crash the spawn. TRIPWIRE: if a future CLI starts reporting `supportsAutoMode`, +/// this omission becomes a real divergence — thread the current model's flag in and +/// prepend the `auto` entry below. +/// - `bypassPermissions` — bridge gates on `ALLOW_BYPASS` (`!IS_ROOT || +/// $IS_SANDBOX`), i.e. shown for every non-root user. We advertise it +/// unconditionally (per product decision: bypass always selectable), which matches +/// the legacy path for the non-root desktop user it actually runs as. +/// +/// Unlike models/effort these are static (no `initialize`-response discovery). +pub(crate) fn claude_permission_modes() -> Vec { + // `auto` is intentionally absent — see the doc comment (gated on + // supportsAutoMode, which the direct CLI never reports). Order and copy match the + // bridge's buildAvailableModes verbatim for the remaining five. + [ + ( + "default", + "Default", + "Standard behavior, prompts for dangerous operations", + ), + ("acceptEdits", "Accept Edits", "Auto-accept file edit operations"), + ("plan", "Plan Mode", "Planning mode, no actual tool execution"), + ( + "dontAsk", + "Don't Ask", + "Don't prompt for permissions, deny if not pre-approved", + ), + ( + "bypassPermissions", + "Bypass Permissions", + "Bypass all permission checks", + ), + ] + .into_iter() + .map(|(id, name, description)| ModeInfo { + id: id.to_string(), + name: name.to_string(), + description: Some(description.to_string()), + }) + .collect() +} + +/// Mint the per-kind streaming item_id from a claude `message.id`. claude shares +/// ONE `message.id` across a turn's thinking AND text blocks; suffixing by kind +/// routes each to its own finalizer buffer (→ separate blocks, no thinking↔text +/// leak), mirroring the ACP/aionrs per-kind id convention. `Other` has no +/// streaming path, so it maps to the bare id (never used as a delta item_id). +fn stream_item_key(item_id: &str, kind: StreamBlockKind) -> String { + match kind { + StreamBlockKind::Text => format!("{item_id}:text"), + StreamBlockKind::Thinking => format!("{item_id}:think"), + StreamBlockKind::Other => item_id.to_string(), + } +} + +impl ClaudeAdapter { + pub fn new() -> Self { + Self::default() + } + + /// Drain complete NDJSON lines from the half-line buffer, yielding one + /// `(Option, Vec)` per line. The byte→`Value` parse + /// happens EXACTLY ONCE per line here — this is the parse-once seam the F1 + /// dual-fanout (`ClaudeFanoutParser`) wraps: it derives its a-side + /// (`AgentStreamEvent`) from the SAME `Value` returned here, while the + /// b-side (`SessionEvent`) is this method's `parse_value` output, so the + /// frame→event mapping lives in ONE place (I8, no cross-crate drift). + /// + /// `Value` is `None` for a malformed line (no a-side projection possible) — + /// the b-side still gets the `AdapterSpecific` escape hatch. The half-line + /// buffer persists across calls AND across result spans (persistent + /// multi-turn; the process's stdout does not EOF between turns). + pub fn frame_lines(&mut self, bytes: &[u8]) -> Vec<(Option, Vec)> { + self.buf.extend_from_slice(bytes); + let mut out = Vec::new(); + while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') { + let line_bytes: Vec = self.buf.drain(..=nl).collect(); + // Lossy is fine: malformed UTF-8 → AdapterSpecific via the serde + // failure path below; we never index mid-char. + let line = String::from_utf8_lossy(&line_bytes); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + match serde_json::from_str::(trimmed) { + Ok(v) => { + let events = self.parse_value(&v); + out.push((Some(v), events)); + } + Err(_) => { + // malformed line: opaque escape hatch, never panic. No + // Value ⇒ a-side has nothing to project (the FanoutParser + // emits its own System fallback). + out.push(( + None, + vec![SessionEvent::AdapterSpecific { + tag: "malformed_json".to_string(), + payload: Value::String(trimmed.to_string()), + }], + )); + } + } + } + out + } + + /// 009 R1a: drain a trailing half-line (bytes with no terminating `\n`) at + /// EOF. `frame_lines` only yields complete `\n`-terminated lines, so a final + /// frame truncated mid-write — e.g. an OOM/SIGKILL during the `result` line — + /// otherwise sits in `buf` forever and is silently lost (the turn is then + /// misclassified as crashed/empty and its last content vanishes). The reader + /// MUST call this once after its read loop breaks (EOF), before emitting + /// `Detached`, and run the same sniff/emit processing on the result. Returns + /// empty when the buffer holds nothing parseable (clean EOF on a `\n` + /// boundary, or only whitespace). The buffer is consumed either way. + pub fn flush_tail(&mut self) -> Vec<(Option, Vec)> { + let tail: Vec = std::mem::take(&mut self.buf); + let line = String::from_utf8_lossy(&tail); + let trimmed = line.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + match serde_json::from_str::(trimmed) { + Ok(v) => { + let events = self.parse_value(&v); + vec![(Some(v), events)] + } + // A genuinely truncated final frame is not valid JSON → opaque escape + // hatch (never panic), same as a malformed mid-stream line. The + // crash discriminator still sees a Detached afterward. + Err(_) => vec![( + None, + vec![SessionEvent::AdapterSpecific { + tag: "truncated_tail".to_string(), + payload: Value::String(trimmed.to_string()), + }], + )], + } + } + + /// Map one already-parsed frame `Value` into zero or more canonical events. + /// A single claude frame (e.g. an assistant message with several content + /// blocks) can yield several `SessionEvent`s; unknown frames yield one + /// `AdapterSpecific`. Never panics (I4). This is the SOLE frame→SessionEvent + /// mapping (the b-side of the seam); it takes a `&Value` so the same parsed + /// frame can feed the a-side without re-parsing. + fn parse_value(&mut self, v: &Value) -> Vec { + let ty = v.get("type").and_then(Value::as_str).unwrap_or(""); + match ty { + "system" => self.parse_system(v), + "assistant" => self.parse_assistant(v), + "user" => self.parse_user(v), + "result" => Self::parse_result(v), + // F3 control channel (--permission-prompt-tool stdio). A + // `control_request` whose `request.subtype == can_use_tool` needs a + // user answer → `Permission{request_id}` (the reducer ref-counts; the + // tool/questions detail rides the a-side card). `control_cancel_request` + // retracts a pending one → `PermissionResolved{request_id}`. Other + // control subtypes (keep_alive / streamlined_* / elicitation) carry no + // FSM signal → opaque (the manager declines elicitation on the a-side). + "control_request" => Self::parse_control_request(v), + "control_cancel_request" => Self::parse_control_cancel_request(v), + // R5 (009): `--include-partial-messages` wraps the streaming Anthropic + // events in `stream_event`. The ONE we read is `message_delta`, whose + // `delta.stop_reason` is the REAL-TIME per-turn boundary — it lands as + // soon as a turn's reply finishes, vs the `result` frame which claude + // headless defers to all-background-tasks-done (a workflow pins it 60s+, + // §2 "unlock signal source"). Everything else (message_start / content_block_* / + // message_stop) carries no FSM signal — the regular `assistant`/`user` + // frames already deliver the content — so they stay opaque. + "stream_event" => self.parse_stream_event(v), + // unknown top-level type → opaque catch-all (I8/I13, never panic). + other => vec![SessionEvent::AdapterSpecific { + tag: other.to_string(), + payload: v.clone(), + }], + } + } + + /// system frames: `init` carries no state signal; `api_retry` and the + /// compaction milestones normalize to the backend-neutral `Heartbeat`; + /// anything else is opaque. + fn parse_system(&self, v: &Value) -> Vec { + match v.get("subtype").and_then(Value::as_str).unwrap_or("") { + "init" => Vec::new(), + // network backoff + the no-chunk compaction window both = liveness. + "api_retry" | "compact_boundary" | "compacting" => vec![SessionEvent::Heartbeat], + other => vec![SessionEvent::AdapterSpecific { + tag: format!("system/{other}"), + payload: v.clone(), + }], + } + } + + /// assistant frames carry a `message.content` array of blocks + /// (text / thinking / tool_use). The message `id` is used as item_id. + fn parse_assistant(&mut self, v: &Value) -> Vec { + let msg = v.get("message"); + let item_id = msg + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + // 009 H5: the top-level frame's `parent_tool_use_id` (sibling of `message`) + // attributes this frame's content to a SUBAGENT's turn; `None`/absent = the + // main agent. Read once here, threaded into each emitted ToolCall so the + // conversation TurnFinalizer can hang the tool step under the right node. + let parent_tool_use_id = v.get("parent_tool_use_id").and_then(Value::as_str).map(str::to_string); + let blocks = msg + .and_then(|m| m.get("content")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + // P3 dedup: whether the in-flight message's text/thinking already streamed + // live as `content_block_delta`s. The consolidated frame's matching block is + // then suppressed (the finalizer APPENDS deltas, so re-emitting the full block + // would double the persisted reply). The streamed flags belong to THIS message + // (`stream.item_id`); a consolidated frame for a different/earlier message + // (no matching live stream) falls through to the emit path so nothing is lost + // (resume turns, or a backend that did not stream). Capture them before the + // loop so the per-block `out.push` paths read a stable view. + let streamed_text = self.stream.item_id == item_id && self.stream.streamed_text; + let streamed_thinking = self.stream.item_id == item_id && self.stream.streamed_thinking; + let text_key = stream_item_key(&item_id, StreamBlockKind::Text); + let think_key = stream_item_key(&item_id, StreamBlockKind::Thinking); + + let mut out = Vec::new(); + for b in &blocks { + match b.get("type").and_then(Value::as_str).unwrap_or("") { + // Suppress the consolidated text block if it already streamed live + // (else fall through and emit the whole block — resume / no-stream path). + "text" if streamed_text => {} + "text" => out.push(SessionEvent::MessageDelta { + // Per-kind item_id so text and thinking land in SEPARATE finalizer + // buffers (claude shares one message.id across both blocks). + item_id: text_key.clone(), + text: b.get("text").and_then(Value::as_str).unwrap_or("").to_string(), + }), + "thinking" if streamed_thinking => {} + "thinking" => out.push(SessionEvent::ThoughtDelta { + item_id: think_key.clone(), + text: b.get("thinking").and_then(Value::as_str).unwrap_or("").to_string(), + }), + "tool_use" => { + let name = b.get("name").and_then(Value::as_str).unwrap_or("").to_string(); + // #486 (parity with aionrs output_sink): DROP a malformed empty-name + // tool_use before it reaches persistence. claude occasionally emits a + // tool_use block with a missing/blank `name`; emitting it produces a + // nameless `tool_step{name:""}` row that renders as a ghost tool line + // (LIVE-observed 2026-06-22). A blank name carries no actionable info, + // so suppress the ToolCall entirely (the paired tool_result, keyed on + // tool_use_id, then finds no call and is inert). warn, not info: this + // is malformed-but-handled upstream data (AGENTS.md); never log `input`. + if name.trim().is_empty() { + tracing::warn!(item_id = %item_id, "claude tool_use has an empty name; dropping malformed call"); + } else { + out.push(SessionEvent::ToolCall { + tool_use_id: b.get("id").and_then(Value::as_str).unwrap_or("").to_string(), + name, + // 002/F1 single-agent path: inline tool. subagent topology + // (Task/Workflow → Spawned/Workflow) is the new ClaudeConnection's + // job (007 §9.14); this legacy adapter stays Inline. + subagent: crate::event::SubagentKind::Inline, + // Gap #4 / H2: carry the tool ARGUMENTS (Anthropic `input` object). + // Absent → Value::Null. TIO-13: never logged at info. + input: b.get("input").cloned().unwrap_or(Value::Null), + // 009 H5: attribute to the subagent's turn (frame-level), main = None. + parent_tool_use_id: parent_tool_use_id.clone(), + }); + } + } + // unknown block type: opaque, never panic. + other => out.push(SessionEvent::AdapterSpecific { + tag: format!("assistant/{other}"), + payload: b.clone(), + }), + } + } + out + } + + /// user frames carry synthesized `tool_result` blocks, referring back by + /// tool_use_id. + fn parse_user(&self, v: &Value) -> Vec { + // 009 H5: same top-level attribution as parse_assistant — a subagent's + // tool_result frame carries the parent's tool_use_id beside `message`. + let parent_tool_use_id = v.get("parent_tool_use_id").and_then(Value::as_str).map(str::to_string); + let blocks = v + .get("message") + .and_then(|m| m.get("content")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut out = Vec::new(); + for b in &blocks { + if b.get("type").and_then(Value::as_str) == Some("tool_result") { + out.push(SessionEvent::ToolResult { + tool_use_id: b.get("tool_use_id").and_then(Value::as_str).unwrap_or("").to_string(), + // 009 R7/H3: the wire block carries is_error on a failed/rejected + // tool (default false = success). Carrying it keeps a red tool red. + is_error: b.get("is_error").and_then(Value::as_bool).unwrap_or(false), + // 009 R8: carry the tool OUTPUT the parser used to drop. claude's + // tool_result.content is polymorphic — a String (e.g. Bash stdout) + // or an Array of blocks (text / a base64 `image` block, e.g. a + // Read-tool image). A generated image is written to disk by the + // tool, so it surfaces only as text here (path in the text). + content: parse_tool_result_content(b.get("content")), + // 009 H5: attribute the result to the subagent's turn, main = None. + parent_tool_use_id: parent_tool_use_id.clone(), + }); + } + // non-tool_result user blocks are ignored (not P0-relevant). + } + out + } + + /// terminal result. Routing basis = `is_error` (NEVER subtype, which stays + /// "success" even on error). `api_error_status` and `result` text are + /// normalized to backend-neutral typed fields. + /// + /// `result_text` source (U11), error-terminal fallback chain — prefer the + /// `result` field; then the human-readable `errors[]` array; then (LAST) the + /// `subtype` token itself. The final `subtype` fallback is essential for a + /// failed `--resume`: claude 2.1.168 emits `result{subtype: + /// "error_during_execution", is_error:true}` with NO `result` and NO + /// `errors[]` — the real cause ("No conversation found …") is on STDERR, + /// which this frame-parser cannot see. Without the subtype fallback the + /// message would be EMPTY → the reducer's `Error{Backend{message:""}}` → + /// the crash-resume self-heal (`is_unrecoverable_resume_error`) could not + /// detect it → permanent resume wedge. Mirrors the official ACP adapter, + /// which uses `errors.join() || subtype` for `error_during_execution`. + /// + /// The `subtype` fallback is gated on `is_error:true` — a SUCCESS turn must + /// keep an empty result_text (an `is_error:false` empty turn carries + /// `subtype:"success"`, and leaking that token would break the reducer's + /// EmptyTurn detection, which keys on `result_text.is_empty()`). + /// A `result` frame → the terminal `TurnResult` PLUS (C-2) a `UsageDelta` when + /// the frame carries `usage` (claude direct-CLI puts token usage + cost INLINE + /// on the same result frame). Returns a Vec so the usage rides alongside the + /// terminal — codex already emits UsageDelta (map_usage); this closes the + /// claude/codex asymmetry. The wrapping ClaudeConnection inherits both for free. + fn parse_result(v: &Value) -> Vec { + let is_error = v.get("is_error").and_then(Value::as_bool).unwrap_or(false); + let result_text = match v.get("result").and_then(Value::as_str) { + Some(s) if !s.is_empty() => s.to_string(), + _ => { + let from_errors = v + .get("errors") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + // Drop claude's internal `[ede_diagnostic] …` template — it + // is a CLI-internal debug string (result_type/last_content_type/ + // stop_reason), NOT a user-readable error. claude emits it in + // `errors[]` for an abnormal-but-contentless terminal (e.g. a + // turn interrupted before any assistant output), so without + // this filter a cancel surfaced "[ede_diagnostic] …" verbatim + // as the error message. Precise prefix match on claude's own + // tag — genuine user-readable errors[] entries are kept. + .filter(|e| !e.trim_start().starts_with("[ede_diagnostic]")) + .collect::>() + .join("; ") + }) + .unwrap_or_default(); + match (from_errors.is_empty(), is_error) { + // result + errors both empty AND this is an ERROR terminal → + // last resort: the structural failure token (e.g. + // "error_during_execution"), so the message is non-empty and + // the self-heal can detect a stderr-only resume failure. + (true, true) => v.get("subtype").and_then(Value::as_str).unwrap_or("").to_string(), + // errors present → use it; OR a SUCCESS terminal → stay empty + // (never leak "success" into result_text; EmptyTurn needs it + // empty). + _ => from_errors, + } + } + }; + let mut out = vec![SessionEvent::TurnResult { + is_error, + api_error_status: v.get("api_error_status").and_then(Value::as_u64).map(|n| n as u16), + result_text, + // Epoch-agnostic: the adapter parses one frame with no turn context. + // The ai-agent reader stamps the live epoch when forwarding the b-side + // event (run_session read_loop). 0 = "unstamped" (settles normally for + // backends that never cancel mid-turn). + epoch: 0, + // C-1 (007 §C2/O3): map claude's terminal to the typed outcome. Prefer + // the rich `terminal_reason` (12-value enum); fall back to `stop_reason`. + outcome: Self::result_outcome(v), + }]; + // C-2: emit a UsageDelta when the result carries `usage`. claude usage is + // snake_case {input_tokens, output_tokens, cache_creation_input_tokens, + // cache_read_input_tokens}; there is no `total_tokens` field, so compute it. + // H4 FIX (race/scenario audit): the true total is base input + output PLUS + // BOTH cache buckets — cache_read/cache_creation ARE billed input tokens, so + // omitting them under-reported the total ~10x on a cache-heavy turn (and was + // inconsistent with codex, whose native `last.totalTokens` already includes + // cache). `input_tokens`/`output_tokens` stay the wire's base counts; only + // `total_tokens` becomes the genuine total. cost from total_cost_usd. + if let Some(usage) = v.get("usage").and_then(Value::as_object) { + let get = |k: &str| usage.get(k).and_then(Value::as_u64).unwrap_or(0); + let input_tokens = get("input_tokens"); + let output_tokens = get("output_tokens"); + let cache_creation = get("cache_creation_input_tokens"); + let cache_read = get("cache_read_input_tokens"); + let total_tokens = usage + .get("total_tokens") + .and_then(Value::as_u64) + .unwrap_or(input_tokens + output_tokens + cache_creation + cache_read); + out.push(SessionEvent::UsageDelta { + input_tokens, + output_tokens, + total_tokens, + cost_usd: v.get("total_cost_usd").and_then(Value::as_f64), + }); + } + out + } + + /// C-1: map a claude result frame's `terminal_reason` (preferred, 12-value) or + /// `stop_reason` to the typed `TurnOutcome`. Unknown/end_turn → EndTurn; + /// max_tokens/max-turns → Truncated; refusal → Refused; cancelled → Cancelled. + fn result_outcome(v: &Value) -> crate::event::TurnOutcome { + use crate::event::{CancelReason, StopReason, TruncationKind, TurnOutcome}; + let reason = v + .get("terminal_reason") + .and_then(Value::as_str) + .or_else(|| v.get("stop_reason").and_then(Value::as_str)) + .unwrap_or(""); + match reason { + "cancelled" | "aborted_streaming" | "aborted_tools" => TurnOutcome::Cancelled { + reason: CancelReason::UserCancel, + }, + "max_tokens" | "prompt_too_long" => TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::MaxTokens), + }, + "max_turns" => TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::MaxTurns), + }, + "refusal" => TurnOutcome::Completed { + stop_reason: StopReason::Refused { category: None }, + }, + // end_turn / stop_sequence / model_error / hook_* / unknown → EndTurn + // (errors route via is_error, not outcome). + _ => TurnOutcome::Completed { + stop_reason: StopReason::EndTurn, + }, + } + } + + /// R5 (009): parse a `--include-partial-messages` `stream_event` frame. The + /// only sub-event with an FSM signal is `message_delta`, whose + /// `event.delta.stop_reason` ends a turn in REAL TIME: + /// - `tool_use` → the turn continues (a tool round follows); emit NOTHING, + /// so the FSM stays Running. (Emitting a TurnResult here would fold Idle + /// mid-turn and let the user "send" into a still-running turn.) + /// - `end_turn` / `max_tokens` / `max_turns` / `refusal` (any terminal stop) + /// → emit a real-time `TurnResult{is_error:false}` so the FSM folds Idle + /// NOW, not when the lagged `result` frame finally arrives. result_text is + /// empty (the content already streamed as MessageDelta, which set + /// saw_substantive_output, so OUTPUT-PRESENCE folds Idle not EmptyTurn). + /// The later `result` TurnResult is harmlessly absorbed by I10 (terminal). + /// + /// P3 partial streaming: the content sub-events drive incremental output — + /// - `message_start` → record `message.id` as the per-message item_id stem + + /// RESET the per-message stream state (block-kind map + streamed flags). + /// - `content_block_start` → record `index → kind` (text / thinking / other) + /// so the deltas (which carry only `index`) can be routed. + /// - `content_block_delta{text_delta}` → emit an incremental `MessageDelta` + /// (item_id `:text`) — this is the typewriter stream. The + /// consolidated `assistant` text block is then suppressed (dedup) so the + /// finalizer (which APPENDS) does not double the persisted reply. + /// `thinking_delta` → `ThoughtDelta` (`:think`) for direct-API backends; + /// Bedrock never emits it (LIVE-probed), so thinking falls back to the + /// consolidated frame. `input_json_delta`/`signature_delta` carry no display + /// text → emit nothing (tool_use args arrive intact on the `assistant` frame). + /// - `content_block_stop` / `message_stop` → no signal, emit nothing. + /// + /// The per-kind item_id (`:text`/`:think`) is load-bearing: claude shares ONE + /// `message.id` across a turn's thinking AND text blocks, so a single-keyed + /// finalizer buffer would merge them; the suffix routes each kind to its own + /// buffer → separate blocks, no leak. + fn parse_stream_event(&mut self, v: &Value) -> Vec { + let event = v.get("event"); + let sub_ty = event.and_then(|e| e.get("type")).and_then(Value::as_str).unwrap_or(""); + match sub_ty { + "message_start" => { + self.stream.item_id = event + .and_then(|e| e.get("message")) + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + self.stream.block_kind.clear(); + self.stream.streamed_text = false; + self.stream.streamed_thinking = false; + Vec::new() + } + "content_block_start" => { + if let Some(index) = event.and_then(|e| e.get("index")).and_then(Value::as_u64) { + let kind = match event + .and_then(|e| e.get("content_block")) + .and_then(|b| b.get("type")) + .and_then(Value::as_str) + .unwrap_or("") + { + "text" => StreamBlockKind::Text, + "thinking" => StreamBlockKind::Thinking, + _ => StreamBlockKind::Other, + }; + self.stream.block_kind.insert(index, kind); + } + Vec::new() + } + "content_block_delta" => { + let index = event.and_then(|e| e.get("index")).and_then(Value::as_u64); + let kind = index.and_then(|i| self.stream.block_kind.get(&i).copied()); + let delta = event.and_then(|e| e.get("delta")); + let delta_ty = delta.and_then(|d| d.get("type")).and_then(Value::as_str).unwrap_or(""); + match (kind, delta_ty) { + (Some(StreamBlockKind::Text), "text_delta") => { + let text = delta.and_then(|d| d.get("text")).and_then(Value::as_str).unwrap_or(""); + if text.is_empty() { + return Vec::new(); + } + self.stream.streamed_text = true; + vec![SessionEvent::MessageDelta { + item_id: stream_item_key(&self.stream.item_id, StreamBlockKind::Text), + text: text.to_string(), + }] + } + (Some(StreamBlockKind::Thinking), "thinking_delta") => { + let text = delta + .and_then(|d| d.get("thinking")) + .and_then(Value::as_str) + .unwrap_or(""); + if text.is_empty() { + return Vec::new(); + } + self.stream.streamed_thinking = true; + vec![SessionEvent::ThoughtDelta { + item_id: stream_item_key(&self.stream.item_id, StreamBlockKind::Thinking), + text: text.to_string(), + }] + } + // input_json_delta (tool args) / signature_delta (thinking sig) / + // any other → no display text. tool_use args arrive intact on the + // consolidated `assistant` frame; we do not reconstruct partials. + _ => Vec::new(), + } + } + // No FSM signal, now an EXPECTED frame (not unknown) → emit nothing. + "content_block_stop" | "message_stop" => Vec::new(), + "message_delta" => { + let stop_reason = event + .and_then(|e| e.get("delta")) + .and_then(|d| d.get("stop_reason")) + .and_then(Value::as_str) + .unwrap_or(""); + match stop_reason { + // Turn continues — a tool round follows. No fold. + "" | "tool_use" => Vec::new(), + // Any terminal stop_reason → real-time turn end. Map via the shared + // outcome helper (reads `delta.stop_reason`); empty result_text + // (content streamed already), is_error:false (errors arrive on the + // result frame). + _ => vec![SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: String::new(), + epoch: 0, + outcome: Self::result_outcome(event.and_then(|e| e.get("delta")).unwrap_or(&Value::Null)), + }], + } + } + // genuinely unknown sub-type → opaque escape hatch (I8/I13, never panic). + _ => vec![SessionEvent::AdapterSpecific { + tag: format!("stream_event/{sub_ty}"), + payload: v.clone(), + }], + } + } + + /// A `control_request` frame. ONLY the `can_use_tool` subtype that needs a + /// user decision maps to a b-side `Permission` (ref-count +1, correlation key + /// `request_id`). Every other control subtype (`keep_alive`, + /// `streamlined_*`, `elicitation`, an unknown one) carries no FSM signal and + /// is opaque — the reducer must never block the turn on a non-permission + /// control frame. `request_id` is the TOP-LEVEL field (distinct from the + /// nested `request.tool_use_id`); a frame missing it cannot be answered, so + /// it degrades to opaque rather than wedging a request we can't resolve. + fn parse_control_request(v: &Value) -> Vec { + let subtype = v + .get("request") + .and_then(|r| r.get("subtype")) + .and_then(Value::as_str) + .unwrap_or(""); + let request_id = v.get("request_id").and_then(Value::as_str).unwrap_or(""); + match (subtype, request_id.is_empty()) { + ("can_use_tool", false) => { + let request = v.get("request"); + let tool_name = request + .and_then(|r| r.get("tool_name")) + .and_then(Value::as_str) + .map(str::to_string); + // Carry the raw `input` ONLY for AskUserQuestion — its + // `{questions:[…]}` is question CONTENT meant for the user, so the + // frontend can render a real question card. For every other tool the + // `input` is a command body / file contents (TIO-13 sensitive) and is + // deliberately dropped here — the generic allow/deny card needs no + // payload. `tool_name` itself is non-sensitive (a tool label) and is + // always carried so the conversation layer can tell AskUserQuestion + // apart from an ordinary approval. + let input = if tool_name.as_deref() == Some("AskUserQuestion") { + request.and_then(|r| r.get("input")).cloned() + } else { + None + }; + vec![SessionEvent::Permission { + request_id: request_id.to_string(), + // 007 §9.17: claude can_use_tool is a TOOL approval (not auth). + kind: crate::event::PermissionKind::Tool, + // G3 auto-approval is ACP-only; no team-MCP approval flows here, so + // no allowlist context to carry. (Under the production `default` + // permission mode, claude raises can_use_tool for routine tools too + // — Write/Bash PROMPT, LIVE 2.1.191 — not only AskUserQuestion; this + // arm correctly surfaces a Permission for ANY can_use_tool.) + metadata: None, + tool_name, + input, + }] + } + _ => vec![SessionEvent::AdapterSpecific { + tag: format!("control_request/{subtype}"), + payload: v.clone(), + }], + } + } + + /// A `control_cancel_request` frame: claude retracts a pending permission. + /// Maps to `PermissionResolved{request_id}` (ref-count -1) so the + /// requires-action sub-state can clear without a user answer. The host sends + /// NO control_response for a cancel (the request is gone); the manager drops + /// the pending card on the a-side. + fn parse_control_cancel_request(v: &Value) -> Vec { + match v.get("request_id").and_then(Value::as_str) { + Some(id) if !id.is_empty() => vec![SessionEvent::PermissionResolved { + request_id: id.to_string(), + // claude control_cancel_request retracts a TOOL approval (§9.17). + kind: crate::event::PermissionKind::Tool, + }], + _ => vec![SessionEvent::AdapterSpecific { + tag: "control_cancel_request".to_string(), + payload: v.clone(), + }], + } + } +} + +#[async_trait::async_trait] +impl BackendAdapter for ClaudeAdapter { + async fn start_turn( + &self, + spawner: &dyn Spawner, + session: &SessionSpec, + cwd: Option<&str>, + extra_args: &[String], + env: &[aionui_common::EnvVar], + ) -> Result, ProcessError> { + // Feature 004 R2/D2: persistent stream-json-input process (multi-turn). + // The prompt is NOT a spawn arg — it is delivered per-turn over stdin via + // `deliver_prompt`. D7: bare `claude` is PATH-resolved by the 001 Builder. + // + // SessionSpec → the session flag (R16/D12/S17, live-probed): a fresh id + // uses `--session-id`; a resume uses `--resume` (reusing `--session-id` + // for an existing id hard-errors `already in use`, so continuation is + // ONLY via `--resume`). + let mut args = vec![ + "--print".to_string(), + "--input-format".to_string(), + "stream-json".to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + // R5 (009): stream partial messages so the per-turn boundary arrives + // in REAL TIME as `stream_event{message_delta{stop_reason}}`. Without + // this flag the only turn-end signal is the `result` frame, which + // claude headless defers until ALL background tasks finish — a + // Workflow pins it 60s+, so the FSM (which folds Idle on TurnResult) + // stays locked through the whole flight period (§2 unlock signal source). The + // adapter maps message_delta{end_turn} → a real-time TurnResult; the + // later result TurnResult is absorbed by the reducer's I10. + "--include-partial-messages".to_string(), + "--replay-user-messages".to_string(), + // Feature 004 F3: enable the bidirectional control channel. With + // `--permission-prompt-tool stdio`, claude emits a `control_request` + // (subtype `can_use_tool`) on stdout and BLOCKS until the host writes + // a matching `control_response` to stdin. Under the PRODUCTION + // `--permission-mode default` (the fail-closed spawn default, + // build_claude_init_args), routine tools (Write/Bash) PROMPT via + // can_use_tool too — LIVE 2.1.191, NOT only AskUserQuestion (an earlier + // note claimed routine tools auto-run; that was the 2.1.168 bypass-mode + // behavior, stale for the default mode we now ship). Our reader surfaces + // a Permission for ANY can_use_tool, so all are answerable. Without this + // flag claude auto-stubs AskUserQuestion and the user can never answer. + "--permission-prompt-tool".to_string(), + "stdio".to_string(), + ]; + match session { + SessionSpec::Fresh(id) => { + args.push("--session-id".to_string()); + args.push(id.clone()); + } + SessionSpec::Resume(id) => { + args.push("--resume".to_string()); + args.push(id.clone()); + } + } + // Manager-supplied flags (S18: --system-prompt / --mcp-config / + // --strict-mcp-config). Kept backend-neutral: the adapter does not build + // them, only appends them. + args.extend(extra_args.iter().cloned()); + + let spec = CommandSpec { + command: "claude".into(), + args, + // #103: provider env injected by the orchestration layer (e.g. cc-switch + // ANTHROPIC_BASE_URL/AUTH_TOKEN for backend == "claude"). Empty = + // inherit parent env only (pre-#103 byte-identical). The adapter only + // forwards it — it never reads cc-switch. + env: env.to_vec(), + // The conversation workspace: claude runs (and its file tools + // operate) here, AND claude keys its on-disk session by cwd — so a + // later `--resume` only finds the session when respawned with the + // SAME cwd. Threading the workspace here is what makes cross-process + // resume (idle-reap / backend-restart respawn) actually work. + cwd: cwd.map(str::to_owned), + }; + // S14: spawn via the INJECTED spawner (never raw-spawn). opaque_owner_tag + // is passed through verbatim; P0 uses a static tag. + let proc = spawner.spawn(spec, &[], "aionui-session").await?; + Ok(Box::new(ManagedProcessIo::new(proc))) + } + + async fn deliver_prompt( + &self, + stdin: &mut BoxedStdin, + content: &[crate::ContentBlock], + client_msg_id: Option<&str>, + ) -> Result<(), ProcessError> { + use crate::ContentBlock; + use base64::Engine as _; + + // S16: build the user line with serde_json over TYPED values (never + // format!/interpolation) so any \n / " / \ / unicode is escaped into + // exactly one NDJSON frame. The whole multimodal block slice collapses + // into ONE user frame's `content[]` array (a raw-newline-bearing text + // element must not split the frame). + let blocks: Vec = content + .iter() + .filter_map(|b| match b { + ContentBlock::Text(t) => Some(json!({ "type": "text", "text": t })), + // Native base64 image block — the ONLY inline non-text modality + // headless claude accepts. The `source` wrapper is REQUIRED; a flat + // {type:image,data,...} is rejected (error_during_execution). + // Pinned: protocols/samples/claude-cli/2.1.177/image_input_frame.OK.json + ContentBlock::Image { data, media_type } => { + let b64 = base64::engine::general_purpose::STANDARD.encode(data); + Some(json!({ + "type": "image", + "source": { "type": "base64", "media_type": media_type, "data": b64 } + })) + } + // claude has no working document/resource INPUT block, so a file is + // delivered by REFERENCE: a text element naming the path, which + // claude's Read tool fetches from cwd (the file must be reachable + // from the spawn cwd / an --add-dir root). + ContentBlock::ResourceLink { uri, .. } => { + Some(json!({ "type": "text", "text": format!("[Attached file: {uri}]") })) + } + // Audio / AtMention are not advertised in prompt_blocks, so dispatch + // rejects them before we get here — defensive skip. + _ => None, + }) + .collect(); + let mut line = json!({ + "type": "user", + "message": { "role": "user", "content": blocks } + }); + // Stamp OUR correlation id as the frame's `uuid`. claude echoes it verbatim + // in the `--replay-user-messages` frame BEFORE that turn's result, which is + // how the ClaudeConnection reader attributes which client_msg_id a turn + // consumed (incl. merged/steered turns where N sends map to M Result<(), ProcessError> { + // F3: the caller built the full `{"type":"control_response","response": + // {"subtype":"success"|"error","request_id":,...}}` per the frozen + // contract; we only frame + flush it (same stdin, same NDJSON rules as a + // user line — single `\n`, do NOT close). + write_ndjson_line(stdin, value).await + } + + fn parse_chunk(&mut self, bytes: &[u8]) -> Vec { + // Thin wrapper over the parse-once seam: drop the per-line `Value` + // (b-side only). `frame_lines` does the byte-buffering + one JSON parse + // per complete line. The F1 dual-fanout calls `frame_lines` directly to + // also get the `Value` for its a-side projection. + self.frame_lines(bytes) + .into_iter() + .flat_map(|(_v, events)| events) + .collect() + } + + fn capabilities(&self) -> Capabilities { + // claude is fully parsed and emits all three signals. + // 007 §C6: the legacy ClaudeAdapter declares the DISCOVERY fields too. + // claude headless (stream-json control plane) supports answer_permission + // (control_response) but rewind is NOT WIRED YET — deferred, not impossible. + // (Correction, gap-reaudit: the prior "§9.9 measured: /rewind not on the + // control plane" was WRONG — 2.1.191 binary HAS rewind_files/rewind_conversation + // control arms; a probe returns {canRewind, error}.) The protocol EXISTS; we + // just haven't built the client side: it needs a num_turns→user_message_id + // history map (the wire rewinds by message id, not turn count) + checkpoint + // infra (CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING), and a live success path we + // can't verify without a checkpoint-enabled session. So cap is false FOR NOW; + // this is a known, reachable follow-up, not a permanent exclusion — wire it + // when rewind UX is wanted (probe shapes captured: protocols/samples/claude-cli/ + // 2.1.187/_all_rewind_{off,on}.jsonl). NOT answer_auth (auth failures terminate + // the turn, no mid-session re-auth on the local path). models/modes are + // CLI-static (filled by ClaudeConnection). + Capabilities { + tier: CapabilityTier::Parsed, + emits: SignalSet { + heartbeat: true, + tool_lifecycle: true, + terminal_result: true, + }, + supported_commands: crate::capability::CommandSet { + steer: false, + cancel_tool: false, + answer_permission: true, + answer_auth: false, + acknowledge: true, + // G2: set_mode/set_model = true — the 007 claude seam now wires the + // in-band control_request{set_permission_mode|set_model} over the + // retained stdin (probe-verified, mirrors F1), queuing a mid-turn + // switch to the next prompt and emitting ConfigChanged on dispatch. + // cap=true ↔ dispatch accepts (the cap-behavior invariant holds). + set_mode: true, + set_model: true, + rewind: false, // not wired YET (protocol EXISTS in 2.1.191; deferred follow-up, see note above) + list_checkpoints: false, + // claude exposes control_request{get_context_usage}+{get_session_cost} + // (live-confirmed 2.1.186) → QuerySessionInfo dispatch over the same + // in-band control plane, sniffed back as SessionEvent::SessionInfo. + query_session_info: true, + }, + prompt_blocks: crate::capability::BlockSet { + text: true, + // image = true: deliver_prompt emits a native base64 image block + // ({type:image, source:{type:base64,...}}) the model truly sees + // (pinned: protocols/samples/claude-cli/2.1.177/image_input_frame.OK.json). + image: true, + audio: false, + // resource = true: a ResourceLink is delivered by reference as a + // `[Attached file: ]` text element for claude's Read tool + // (headless claude has no working document INPUT block; the file + // must be reachable from the spawn cwd / an --add-dir root). + resource: true, + at_mention: false, + }, + // Native: claude echoes our user-frame `uuid` in the + // `--replay-user-messages` frame ONLY when it truly consumes the message + // into a turn — that echo is a REAL prompt-ack (the ClaudeConnection + // reader's sniff_replay_prompt_ack emits PromptAccepted on it). Was + // Synthesized (flush-ok), which lied for a proactively-queued message that + // claude had not yet drained (or dropped on cancel). See protocols/design/ + // claude-midturn-input-turn-gen-design.md §3.3/§4-B. + prompt_accepted: crate::capability::PromptAcceptedSource::Native, + available_models: Vec::new(), + // claude's permission modes are a FIXED known enum (NOT discovered, unlike + // models/effort which come from the initialize response): the exact values + // `--permission-mode` / control_request{set_permission_mode} accept. Static + // here so `config_options_from_caps` projects a `mode` option (it gates on + // `!available_modes.is_empty()`); without this the mode picker had no data + // even though the write path (SetMode) was fully wired. + available_modes: claude_permission_modes(), + current_model: None, + current_mode: None, + current_effort: None, + auth_methods: Vec::new(), // no mid-session re-auth on local path + // 009 R2: claude's persistent stdin is a FIFO — a write while a turn + // is in flight is buffered and consumed as the next turn, so the conv + // layer CAN proactively queue. This (NOT supported_commands.steer, + // which is false here anyway) is what can_queue gates on. + accepts_proactive_input: true, + // #101: static default empty; the clean-slate ClaudeConnection fills it + // from the control_request{initialize} response (the legacy adapter has + // no discovery wire). capabilities() merges the discovered set on read. + slash_commands: Vec::new(), + } + } +} + +/// Max bytes of textual tool output carried per `Text` part (009 R8) — bounds the +/// Tier-1 row / WS-frame size for a huge tool dump (e.g. a megabyte file Read or a +/// noisy Bash). Truncated text gets a trailing marker. +const TOOL_TEXT_CAP: usize = 16 * 1024; + +/// 009 R8: parse a claude `tool_result` block's `content` (polymorphic: a String, +/// or an Array of blocks) into the backend-neutral `ToolResultContent` Vec the +/// `TurnFinalizer` renders. A `text` block → `Text`; a base64 `image` block (the +/// shape a Read-tool image arrives in) → `Image{media_type, data}` (decoded); +/// anything else (tool_reference, …) is skipped. `None`/absent → empty Vec. +fn parse_tool_result_content(content: Option<&Value>) -> Vec { + use crate::event::ToolResultContent; + use base64::Engine as _; + + fn cap_text(mut s: String) -> String { + if s.len() > TOOL_TEXT_CAP { + // Truncate on a char boundary: `String::truncate` PANICS if the byte + // index splits a multi-byte UTF-8 char (e.g. a 16 KiB Read result whose + // 16384th byte lands mid-CJK-char). A parser panic on the reader task + // silently drops the stdout reader WITHOUT emitting a terminal Detached, + // so the turn never finalizes and the conversation is stuck `pending`. + // `floor_char_boundary` rounds down to the nearest boundary (≤ cap). + s.truncate(s.floor_char_boundary(TOOL_TEXT_CAP)); + s.push_str("…[truncated]"); + } + s + } + + match content { + Some(Value::String(s)) => vec![ToolResultContent::Text(cap_text(s.clone()))], + Some(Value::Array(arr)) => arr + .iter() + .filter_map(|el| match el.get("type").and_then(Value::as_str) { + Some("text") => el + .get("text") + .and_then(Value::as_str) + .map(|t| ToolResultContent::Text(cap_text(t.to_string()))), + Some("image") => { + let src = el.get("source")?; + if src.get("type").and_then(Value::as_str) != Some("base64") { + return None; + } + let media_type = src.get("media_type").and_then(Value::as_str)?.to_string(); + let data = base64::engine::general_purpose::STANDARD + .decode(src.get("data").and_then(Value::as_str)?) + .ok()?; + Some(ToolResultContent::Image { media_type, data }) + } + _ => None, // tool_reference / unknown — skip + }) + .collect(), + _ => Vec::new(), + } +} + +/// Serialize `value` as exactly one NDJSON line (serde — never `format!`, so any +/// `\n`/quote/unicode is escaped into one frame, S16) + a single trailing `\n`, +/// then write+flush to the retained stdin WITHOUT closing (the persistent +/// process stays alive for the next turn / control exchange). Shared by +/// `deliver_prompt` (user lines) and `write_control_response` (control frames). +async fn write_ndjson_line(stdin: &mut BoxedStdin, value: &Value) -> Result<(), ProcessError> { + let mut bytes = + serde_json::to_vec(value).map_err(|e| ProcessError::internal(format!("serialize stdin line: {e}")))?; + bytes.push(b'\n'); + // DIAGNOSTIC (env-gated, default OFF via AIONUI_CLAUDE_WIRE_DUMP): log the RAW + // stdin bytes we hand the CLI, so a "send accepted but no output" can be split + // into "prompt was/wasn't actually written" vs "CLI received it but went silent". + // OFF by default — it logs full prompt content (AGENTS.md sensitive-payload rule), + // a deliberate debugging switch, never on in normal production. + if std::env::var("AIONUI_CLAUDE_WIRE_DUMP").is_ok_and(|v| v != "0" && !v.is_empty()) { + let preview = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]); + tracing::info!( + target: "aionui_session::claude_wire", + direction = "stdin", + byte_len = bytes.len(), + preview = %preview, + "claude wire bytes" + ); + } + stdin + .write_all(&bytes) + .await + .map_err(|e| ProcessError::internal(format!("write stdin: {e}")))?; + stdin + .flush() + .await + .map_err(|e| ProcessError::internal(format!("flush stdin: {e}")))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapter::BackendAdapter; + use crate::event::{StopReason, TruncationKind, TurnOutcome}; + + /// A `BoxedStdin` that captures every byte into a shared buffer, so a test can + /// assert the exact NDJSON frame `deliver_prompt` writes. + struct CaptureStdin(std::sync::Arc>>); + impl tokio::io::AsyncWrite for CaptureStdin { + fn poll_write( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + self.0.lock().unwrap().extend_from_slice(buf); + std::task::Poll::Ready(Ok(buf.len())) + } + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + } + + /// Run `deliver_prompt` over a capturing stdin and return the written bytes as + /// a UTF-8 string (the NDJSON line(s)). + async fn captured_frame(content: &[crate::ContentBlock]) -> String { + captured_frame_with_id(content, None).await + } + + /// As `captured_frame`, but stamps a `client_msg_id` so a test can assert the + /// `uuid` correlation field (B / §3.3) is written onto the user frame. + async fn captured_frame_with_id(content: &[crate::ContentBlock], client_msg_id: Option<&str>) -> String { + let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut stdin: aionui_process::BoxedStdin = Box::new(CaptureStdin(buf.clone())); + ClaudeAdapter::new() + .deliver_prompt(&mut stdin, content, client_msg_id) + .await + .unwrap(); + let bytes = buf.lock().unwrap().clone(); + String::from_utf8(bytes).unwrap() + } + + /// The multimodal `deliver_prompt` collapses a Text + Image + ResourceLink + /// slice into EXACTLY ONE user NDJSON frame whose content[] carries: a text + /// block, the NATIVE base64 image block (source-wrapped — the pinned shape in + /// protocols/samples/claude-cli/2.1.177/image_input_frame.OK.json), and a + /// `[Attached file: ]` text reference for the file. Regression guard for + /// the load-bearing image wire shape. + #[tokio::test] + async fn deliver_prompt_builds_multimodal_user_frame() { + let frame = captured_frame(&[ + crate::ContentBlock::Text("hi".into()), + crate::ContentBlock::Image { + data: vec![1, 2, 3], + media_type: "image/png".into(), + }, + crate::ContentBlock::ResourceLink { + uri: "/tmp/doc.pdf".into(), + mime_type: None, + }, + ]) + .await; + // Exactly ONE NDJSON line. + assert_eq!( + frame.matches('\n').count(), + 1, + "exactly one frame written, got {frame:?}" + ); + let v: serde_json::Value = serde_json::from_str(frame.trim_end()).expect("valid JSON frame"); + assert_eq!(v["type"], "user"); + let content = v["message"]["content"].as_array().expect("content array"); + assert_eq!(content.len(), 3, "text + image + file-ref, got {content:?}"); + // [0] text + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[0]["text"], "hi"); + // [1] NATIVE base64 image block — source-wrapped (the REQUIRED shape). + assert_eq!(content[1]["type"], "image"); + assert_eq!(content[1]["source"]["type"], "base64"); + assert_eq!(content[1]["source"]["media_type"], "image/png"); + assert_eq!(content[1]["source"]["data"], "AQID"); // base64([1,2,3]) + // [2] file delivered by reference as a Read-tool text hint. + assert_eq!(content[2]["type"], "text"); + assert_eq!(content[2]["text"], "[Attached file: /tmp/doc.pdf]"); + } + + /// B / §3.3: a `client_msg_id` is stamped as the user frame's top-level `uuid` + /// (the correlation key claude echoes verbatim in its replay frame, so the + /// ClaudeConnection reader can attribute which message a turn consumed). When + /// `None`, NO `uuid` key is written (wire byte-identical to pre-correlation). + #[tokio::test] + async fn deliver_prompt_stamps_client_msg_id_as_uuid() { + let with = captured_frame_with_id(&[crate::ContentBlock::Text("hi".into())], Some("cm-42")).await; + let v: serde_json::Value = serde_json::from_str(with.trim_end()).expect("valid JSON"); + assert_eq!(v["uuid"], "cm-42", "client_msg_id is written as the frame uuid"); + assert_eq!(v["type"], "user"); + assert_eq!(v["message"]["content"][0]["text"], "hi", "content is unaffected"); + + let without = captured_frame_with_id(&[crate::ContentBlock::Text("hi".into())], None).await; + let v2: serde_json::Value = serde_json::from_str(without.trim_end()).expect("valid JSON"); + assert!( + v2.get("uuid").is_none(), + "no client_msg_id → no uuid key (byte-identical wire)" + ); + } + + /// S16 one-frame invariant survives the &str→&[ContentBlock] widening: a text + /// element containing a raw newline + quotes must NOT split the NDJSON frame. + #[tokio::test] + async fn deliver_prompt_text_with_newline_stays_one_frame() { + let frame = captured_frame(&[crate::ContentBlock::Text("a\nb\"c".into())]).await; + assert_eq!( + frame.matches('\n').count(), + 1, + "newline in text must not split the frame: {frame:?}" + ); + let v: serde_json::Value = serde_json::from_str(frame.trim_end()).expect("valid JSON"); + assert_eq!(v["message"]["content"][0]["text"], "a\nb\"c"); + } + + /// 009 R8: a STRING tool_result content → one Text part (e.g. Bash stdout). + #[test] + fn parse_user_tool_result_string_content_to_text() { + let a = ClaudeAdapter::new(); + let frame = r#"{"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"tu1","content":"hello stdout"}]}}"#; + let v: serde_json::Value = serde_json::from_str(frame).unwrap(); + let events = a.parse_user(&v); + match events.as_slice() { + [SessionEvent::ToolResult { content, .. }] => { + assert_eq!( + content.as_slice(), + &[crate::event::ToolResultContent::Text("hello stdout".into())] + ); + } + other => panic!("expected one ToolResult, got {other:?}"), + } + } + + /// #486 (P4, LIVE-found 2026-06-22): a claude `tool_use` block with a + /// missing/blank `name` is DROPPED — no `ToolCall` emitted — so it never + /// persists as a ghost `tool_step{name:""}` row. Mirrors aionrs's empty-name + /// guard. Other blocks in the same frame are unaffected. + #[test] + fn parse_assistant_drops_empty_name_tool_use_keeps_other_blocks() { + let mut a = ClaudeAdapter::new(); + // A frame mixing a text block, a malformed tool_use (no `name`), and a + // whitespace-only-name tool_use. Only the text must survive. + let frame = r#"{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"text","text":"working"}, + {"type":"tool_use","id":"t-missing","input":{}}, + {"type":"tool_use","id":"t-blank","name":" ","input":{}}]}}"#; + let v: serde_json::Value = serde_json::from_str(frame).unwrap(); + let events = a.parse_assistant(&v); + assert!( + !events.iter().any(|e| matches!(e, SessionEvent::ToolCall { .. })), + "empty/blank-name tool_use must NOT emit a ToolCall, got {events:?}" + ); + assert!( + events + .iter() + .any(|e| matches!(e, SessionEvent::MessageDelta { text, .. } if text == "working")), + "a valid sibling block in the same frame still emits, got {events:?}" + ); + } + + /// Regression guard for the other direction: a well-formed `tool_use` (real + /// name) still emits its `ToolCall` — the #486 guard must not over-drop. + #[test] + fn parse_assistant_keeps_named_tool_use() { + let mut a = ClaudeAdapter::new(); + let frame = r#"{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"tool_use","id":"t-ok","name":"Read","input":{"file_path":"/x"}}]}}"#; + let v: serde_json::Value = serde_json::from_str(frame).unwrap(); + let events = a.parse_assistant(&v); + match events.as_slice() { + [ + SessionEvent::ToolCall { + tool_use_id, + name, + input, + .. + }, + ] => { + assert_eq!(tool_use_id, "t-ok"); + assert_eq!(name, "Read"); + // The tool ARGUMENTS (Gap #4 / H2) are load-bearing — the conversation + // layer renders/persists them. A regression that dropped `input` would + // pass a name-only assertion, so pin the full payload here. + assert_eq!( + input, + &serde_json::json!({"file_path": "/x"}), + "tool_use input (arguments) must survive parse, not be dropped" + ); + } + other => panic!("expected one ToolCall(Read), got {other:?}"), + } + + // Boundary: a tool_use with NO `input` key defaults to Value::Null (not a panic, + // not a dropped ToolCall) — older frames / argument-less tools. + let mut a = ClaudeAdapter::new(); + let frame = r#"{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"tool_use","id":"t-bare","name":"NoArgs"}]}}"#; + let v: serde_json::Value = serde_json::from_str(frame).unwrap(); + match a.parse_assistant(&v).as_slice() { + [SessionEvent::ToolCall { name, input, .. }] => { + assert_eq!(name, "NoArgs"); + assert!(input.is_null(), "missing input → Value::Null (additive default)"); + } + other => panic!("expected one ToolCall(NoArgs), got {other:?}"), + } + } + + /// PROPERTY (input field-value invariant, generalizes #486 / bug 201f999d): + /// for ANY assistant frame whose `tool_use` blocks carry an arbitrary `name` + /// (absent / "" / whitespace / valid) and arbitrary `id`, `parse_assistant`: + /// 1. NEVER panics (malformed input is data, not a crash — I4); + /// 2. NEVER emits a `ToolCall` with a blank/whitespace name (the exact ghost + /// tool-line defect 201f999d fixed — pin it across the whole value space, + /// not just the two hand-picked cases above); + /// 3. emits EXACTLY one `ToolCall` per non-blank-name block (no over-drop). + /// + /// This is the §F.3 "input field-value boundary" face the unit tests only + /// sampled — proptest sweeps the value space mechanically. + #[test] + fn prop_parse_assistant_never_emits_blank_name_toolcall() { + use proptest::prelude::*; + let name_strat = prop_oneof![ + Just(None), + Just(Some(String::new())), + Just(Some(" ".to_string())), + Just(Some("\t\n".to_string())), + "[a-zA-Z_][a-zA-Z0-9_]{0,8}".prop_map(Some), + ]; + let blocks_strat = prop::collection::vec((name_strat, "[a-z0-9-]{0,6}"), 0..6); + + proptest!(|(blocks in blocks_strat)| { + let content: Vec = blocks + .iter() + .map(|(name, id)| { + let mut b = serde_json::json!({"type":"tool_use","id":id,"input":{}}); + if let Some(n) = name { + b["name"] = serde_json::Value::String(n.clone()); + } + b + }) + .collect(); + let frame = serde_json::json!({ + "type":"assistant", + "message":{"role":"assistant","content":content} + }); + let mut a = ClaudeAdapter::new(); + let events = a.parse_assistant(&frame); // (1) must not panic + + for e in &events { + if let SessionEvent::ToolCall { name, .. } = e { + prop_assert!( + !name.trim().is_empty(), + "emitted a ToolCall with blank name {name:?} (ghost tool line, 201f999d)" + ); + } + } + let expected = blocks + .iter() + .filter(|(name, _)| name.as_deref().map(|n| !n.trim().is_empty()).unwrap_or(false)) + .count(); + let got = events.iter().filter(|e| matches!(e, SessionEvent::ToolCall { .. })).count(); + prop_assert_eq!(got, expected, "one ToolCall per non-blank-name tool_use; over/under-drop"); + }); + } + + /// 009 R8 (LOAD-BEARING image regression guard): an ARRAY tool_result content + /// with a text block + a base64 `image` block (the shape a Read-tool image + /// arrives in) → [Text, Image{bytes decoded}]. Previously the whole content was + /// dropped, so a claude Read-tool image was invisible at the session layer. + #[test] + fn parse_user_tool_result_array_with_image_to_text_and_image() { + use base64::Engine as _; + let a = ClaudeAdapter::new(); + let b64 = base64::engine::general_purpose::STANDARD.encode([1u8, 2, 3]); + let frame = format!( + r#"{{"type":"user","message":{{"role":"user","content":[ + {{"type":"tool_result","tool_use_id":"tu2","content":[ + {{"type":"text","text":"here is the image"}}, + {{"type":"image","source":{{"type":"base64","media_type":"image/png","data":"{b64}"}}}} + ]}}]}}}}"# + ); + let v: serde_json::Value = serde_json::from_str(&frame).unwrap(); + let events = a.parse_user(&v); + match events.as_slice() { + [SessionEvent::ToolResult { content, .. }] => { + assert_eq!(content.len(), 2, "text + image, got {content:?}"); + assert_eq!( + content[0], + crate::event::ToolResultContent::Text("here is the image".into()) + ); + assert_eq!( + content[1], + crate::event::ToolResultContent::Image { + media_type: "image/png".into(), + data: vec![1, 2, 3], + }, + "the base64 image was decoded into bytes (not dropped)" + ); + } + other => panic!("expected one ToolResult, got {other:?}"), + } + } + + /// 009 R7/H3 (codex·ACP symmetry — claude leg): a FAILED tool_result carries + /// `is_error:true` onto the `ToolResult` so a failed tool is NOT rendered as a + /// success (a red tool stays red). The wire sets `is_error` on a rejected / + /// errored tool; absent = success. ACP had this asserted; claude had the parse + /// code (parse_user) but no failing-tool test. Pairs with the success case + /// (default-false) so the routing bit is pinned on both edges. + #[test] + fn parse_user_failed_tool_result_carries_is_error_true() { + let a = ClaudeAdapter::new(); + // A failed tool: is_error:true + the error text as content. + let frame = r#"{"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"tf1","is_error":true, + "content":"Error: command not found: frobnicate"}]}}"#; + let v: serde_json::Value = serde_json::from_str(frame).unwrap(); + match a.parse_user(&v).as_slice() { + [ + SessionEvent::ToolResult { + tool_use_id, + is_error, + content, + .. + }, + ] => { + assert_eq!(tool_use_id, "tf1"); + assert!( + *is_error, + "H3: a failed tool_result must carry is_error:true (not render as success)" + ); + assert_eq!( + content.as_slice(), + &[crate::event::ToolResultContent::Text( + "Error: command not found: frobnicate".into() + )], + "the error output is carried as content" + ); + } + other => panic!("expected one failed ToolResult, got {other:?}"), + } + + // Control: a tool_result WITHOUT is_error defaults to success (false), so the + // failing case above is a genuine signal, not a constant. + let ok = r#"{"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"ts1","content":"ok"}]}}"#; + let v2: serde_json::Value = serde_json::from_str(ok).unwrap(); + match a.parse_user(&v2).as_slice() { + [SessionEvent::ToolResult { is_error, .. }] => { + assert!(!*is_error, "no is_error field → success (default false)"); + } + other => panic!("expected one ToolResult, got {other:?}"), + } + } + + /// Feed a single `result` frame through the public `parse_chunk` seam and + /// return the `TurnResult`'s outcome (the rider the frontend's turn-end badge + /// reads). Panics if no TurnResult is produced. + fn outcome_of(frame: &str) -> TurnOutcome { + let mut a = ClaudeAdapter::new(); + let line = format!("{frame}\n"); + let events = a.parse_chunk(line.as_bytes()); + events + .into_iter() + .find_map(|e| match e { + SessionEvent::TurnResult { outcome, .. } => Some(outcome), + _ => None, + }) + .expect("result frame must produce a TurnResult") + } + + /// 🖥️ UI-4 — claude `result_outcome()` mapping (terminal_reason / stop_reason → + /// TurnOutcome) backs the truncated/refused/cancelled turn-end badge. This was + /// untested (claude.rs had no test module — the legacy parse tests never reached + /// main). A wrong mapping silently mislabels the badge (e.g. a real max_tokens + /// cutoff shown as a clean completion). Pins every distinct mapping branch. + #[test] + fn ui4_claude_result_outcome_maps_terminal_reason() { + // success result frames carry a terminal_reason/stop_reason rider. + assert_eq!( + outcome_of( + r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","terminal_reason":"max_tokens"}"# + ), + TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::MaxTokens) + }, + "max_tokens → Truncated(MaxTokens)" + ); + assert_eq!( + outcome_of( + r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","terminal_reason":"max_turns"}"# + ), + TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::MaxTurns) + }, + "max_turns → Truncated(MaxTurns)" + ); + assert_eq!( + outcome_of( + r#"{"type":"result","subtype":"success","is_error":false,"result":"","terminal_reason":"refusal"}"# + ), + TurnOutcome::Completed { + stop_reason: StopReason::Refused { category: None } + }, + "refusal → Refused" + ); + assert_eq!( + outcome_of( + r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","terminal_reason":"cancelled"}"# + ), + TurnOutcome::Cancelled { + reason: crate::event::CancelReason::UserCancel + }, + "cancelled → Cancelled(UserCancel)" + ); + // end_turn / unknown / absent → EndTurn (errors route via is_error, not outcome). + assert_eq!( + outcome_of( + r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","terminal_reason":"end_turn"}"# + ), + TurnOutcome::Completed { + stop_reason: StopReason::EndTurn + }, + "end_turn → EndTurn" + ); + assert_eq!( + outcome_of(r#"{"type":"result","subtype":"success","is_error":false,"result":"ok"}"#), + TurnOutcome::Completed { + stop_reason: StopReason::EndTurn + }, + "absent terminal_reason → EndTurn (default)" + ); + // stop_reason fallback when terminal_reason absent. + assert_eq!( + outcome_of( + r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","stop_reason":"max_tokens"}"# + ), + TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::MaxTokens) + }, + "stop_reason fallback: max_tokens → Truncated(MaxTokens)" + ); + } + + // ── 009 R5: real-time turn boundary from --include-partial-messages ────── + + /// Parse a `stream_event` frame, returning its SessionEvents. + fn stream_events(frame: &str) -> Vec { + ClaudeAdapter::new().parse_chunk(format!("{frame}\n").as_bytes()) + } + + #[test] + fn message_delta_tool_use_does_not_fold_the_turn() { + // stop_reason:tool_use ⇒ a tool round follows, the turn is NOT over. Must + // emit NO TurnResult (folding here would unlock mid-turn). + let evs = stream_events( + r#"{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_details":null}}}"#, + ); + assert!( + !evs.iter().any(|e| matches!(e, SessionEvent::TurnResult { .. })), + "tool_use must NOT produce a TurnResult, got {evs:?}" + ); + } + + #[test] + fn message_delta_end_turn_emits_realtime_turn_result() { + // stop_reason:end_turn ⇒ the turn's reply finished in REAL TIME (before + // the lagged `result`). Emit a clean TurnResult so the FSM folds Idle now. + let evs = stream_events( + r#"{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_details":null}}}"#, + ); + let tr = evs.iter().find_map(|e| match e { + SessionEvent::TurnResult { is_error, outcome, .. } => Some((*is_error, outcome.clone())), + _ => None, + }); + assert_eq!( + tr, + Some(( + false, + TurnOutcome::Completed { + stop_reason: StopReason::EndTurn + } + )), + "end_turn → real-time TurnResult{{is_error:false, EndTurn}}, got {evs:?}" + ); + } + + #[test] + fn message_delta_terminal_variants_map_outcome() { + // refusal / max_tokens carry through to the typed outcome on the real-time + // TurnResult (same mapping as the result frame). + let refusal = stream_events( + r#"{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"refusal"}}}"#, + ); + assert!( + refusal.iter().any(|e| matches!( + e, + SessionEvent::TurnResult { + outcome: TurnOutcome::Completed { + stop_reason: StopReason::Refused { .. } + }, + .. + } + )), + "refusal → Refused outcome, got {refusal:?}" + ); + } + + #[test] + fn known_non_delta_stream_events_are_inert_unknown_stays_opaque() { + // P3: content_block_stop / message_stop are now EXPECTED frames (they bound + // the stream we parse) → emit nothing, NOT AdapterSpecific. + for frame in [ + r#"{"type":"stream_event","event":{"type":"content_block_stop","index":0}}"#, + r#"{"type":"stream_event","event":{"type":"message_stop"}}"#, + ] { + assert!( + stream_events(frame).is_empty(), + "expected-but-signalless stream sub-event emits nothing, got events for {frame}" + ); + } + // A genuinely UNKNOWN sub-type still falls to the opaque escape hatch. + let evs = stream_events(r#"{"type":"stream_event","event":{"type":"some_future_subtype"}}"#); + assert!( + evs.iter().all(|e| matches!(e, SessionEvent::AdapterSpecific { .. })), + "unknown stream_event sub-type stays opaque, got {evs:?}" + ); + } + + #[test] + fn content_block_delta_text_streams_incremental_message_deltas() { + // The typewriter path: message_start sets the item_id, content_block_start + // declares index 0 as text, then each text_delta emits one MessageDelta + // keyed `:text`. (One adapter instance across the frames — + // the stream state is per-message, not per-line.) + let mut a = ClaudeAdapter::new(); + let feed = |a: &mut ClaudeAdapter, frame: &str| -> Vec { + a.parse_chunk(format!("{frame}\n").as_bytes()) + }; + assert!( + feed( + &mut a, + r#"{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_x"}}}"# + ) + .is_empty() + ); + assert!( + feed( + &mut a, + r#"{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text"}}}"# + ) + .is_empty() + ); + let d1 = feed( + &mut a, + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}}"#, + ); + let d2 = feed( + &mut a, + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"lo"}}}"#, + ); + for (evs, frag) in [(&d1, "Hel"), (&d2, "lo")] { + match evs.as_slice() { + [SessionEvent::MessageDelta { item_id, text }] => { + assert_eq!(item_id, "msg_x:text", "per-kind text item_id"); + assert_eq!(text, frag); + } + other => panic!("expected one MessageDelta({frag}), got {other:?}"), + } + } + + // The consolidated assistant frame for the SAME message must NOT re-emit the + // text (it already streamed) — else the finalizer (which appends) doubles it. + // tool_use in the same frame is still emitted (dedup never touches tools). + let consolidated = feed( + &mut a, + r#"{"type":"assistant","message":{"id":"msg_x","role":"assistant","content":[{"type":"text","text":"Hello"},{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}"#, + ); + assert!( + !consolidated + .iter() + .any(|e| matches!(e, SessionEvent::MessageDelta { .. })), + "streamed text must be deduped on the consolidated frame, got {consolidated:?}" + ); + assert!( + consolidated + .iter() + .any(|e| matches!(e, SessionEvent::ToolCall { name, .. } if name == "Bash")), + "tool_use is never deduped, got {consolidated:?}" + ); + } + + #[test] + fn consolidated_assistant_without_prior_stream_still_emits_text() { + // Resume / no-partial-stream path: an assistant frame with NO preceding + // content_block_delta must still emit its text (keyed per-kind) so nothing is + // lost. Different message.id than any stream state → not deduped. + let mut a = ClaudeAdapter::new(); + let evs = a.parse_chunk( + concat!( + r#"{"type":"assistant","message":{"id":"msg_y","role":"assistant","content":[{"type":"text","text":"full reply"}]}}"#, + "\n" + ) + .as_bytes(), + ); + match evs.as_slice() { + [SessionEvent::MessageDelta { item_id, text }] => { + assert_eq!(item_id, "msg_y:text"); + assert_eq!(text, "full reply"); + } + other => panic!("expected one MessageDelta(full reply), got {other:?}"), + } + } + + #[test] + fn thinking_and_text_same_message_get_distinct_item_ids() { + // claude shares ONE message.id across thinking + text blocks. The per-kind + // suffix routes them to SEPARATE finalizer buffers so thinking never leaks + // into the answer block. (Consolidated-frame path, the Bedrock reality where + // thinking does not stream as thinking_delta.) + let mut a = ClaudeAdapter::new(); + let evs = a.parse_chunk( + concat!( + r#"{"type":"assistant","message":{"id":"msg_z","role":"assistant","content":[{"type":"thinking","thinking":"pondering"},{"type":"text","text":"answer"}]}}"#, + "\n" + ) + .as_bytes(), + ); + let think = evs.iter().find_map(|e| match e { + SessionEvent::ThoughtDelta { item_id, text } => Some((item_id.clone(), text.clone())), + _ => None, + }); + let msg = evs.iter().find_map(|e| match e { + SessionEvent::MessageDelta { item_id, text } => Some((item_id.clone(), text.clone())), + _ => None, + }); + assert_eq!(think, Some(("msg_z:think".to_string(), "pondering".to_string()))); + assert_eq!(msg, Some(("msg_z:text".to_string(), "answer".to_string()))); + } + + /// H4 (design-vs-code gap audit, §5): `UsageDelta.total_tokens` MUST count the + /// cache buckets (`cache_read_input_tokens` + `cache_creation_input_tokens`), + /// which ARE billed input tokens — omitting them under-reported a cache-heavy + /// turn ~10x and disagreed with codex (whose native `last.totalTokens` already + /// includes cache). `input_tokens`/`output_tokens` stay the wire's BASE counts; + /// only `total_tokens` is the genuine total. (Was a tripwire pinning the inert + /// under-report; flipped to assert the fix.) `cost_usd` MUST carry through from + /// `total_cost_usd` (previously never value-asserted). + #[test] + fn usage_delta_total_includes_cache_tokens() { + let mut a = ClaudeAdapter::new(); + // The single_tool_turn fixture's result usage: input=3601, output=394, + // cache_creation=3973, cache_read=33256. True total = 3601+394+3973+33256 + // = 41224 (NOT 3995). (One NDJSON line — frame_lines splits on '\n'.) + let line = concat!( + r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","#, + r#""usage":{"input_tokens":3601,"cache_creation_input_tokens":3973,"#, + r#""cache_read_input_tokens":33256,"output_tokens":394},"total_cost_usd":0.1173}"#, + "\n" + ) + .to_string(); + let (input, output, total, cost) = a + .parse_chunk(line.as_bytes()) + .into_iter() + .find_map(|e| match e { + SessionEvent::UsageDelta { + input_tokens, + output_tokens, + total_tokens, + cost_usd, + } => Some((input_tokens, output_tokens, total_tokens, cost_usd)), + _ => None, + }) + .expect("result frame with usage emits a UsageDelta"); + assert_eq!( + (input, output), + (3601, 394), + "input/output stay the wire's BASE counts (cache is NOT folded into them)" + ); + assert_eq!( + total, + 3601 + 394 + 3973 + 33256, + "H4: total_tokens counts base input + output + BOTH cache buckets (41224), \ + not input+output only (the prior ~10x under-report)" + ); + assert_eq!(cost, Some(0.1173), "cost_usd carries through from total_cost_usd"); + } + + /// H4 boundary: when the cache buckets are absent (a non-cached turn), the + /// computed total is just base input + output — the fix must not over-count + /// missing fields (unwrap_or(0)). + #[test] + fn usage_delta_total_is_base_when_no_cache_fields() { + let mut a = ClaudeAdapter::new(); + let line = concat!( + r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","#, + r#""usage":{"input_tokens":100,"output_tokens":20}}"#, + "\n" + ) + .to_string(); + let (total, cost) = a + .parse_chunk(line.as_bytes()) + .into_iter() + .find_map(|e| match e { + SessionEvent::UsageDelta { + total_tokens, cost_usd, .. + } => Some((total_tokens, cost_usd)), + _ => None, + }) + .expect("UsageDelta emitted"); + assert_eq!(total, 120, "no cache buckets → total is base input+output"); + assert_eq!(cost, None, "no total_cost_usd → cost_usd is None"); + } + + /// AskUserQuestion projection (forward path): a `can_use_tool` for + /// `AskUserQuestion` carries its `{questions:[…]}` input through on the + /// `Permission` event, so the conversation layer can render a question card. + #[test] + fn ask_user_question_permission_carries_tool_name_and_questions() { + let frame = serde_json::json!({ + "type": "control_request", + "request_id": "req-q1", + "request": { + "subtype": "can_use_tool", + "tool_name": "AskUserQuestion", + "tool_use_id": "toolu-q1", + "input": { + "questions": [ + { "question": "Pick a color", "header": "Color", + "options": [{"label":"blue","description":"the sky"}], "multiSelect": false } + ] + } + } + }); + let events = ClaudeAdapter::parse_control_request(&frame); + match events.as_slice() { + [ + SessionEvent::Permission { + request_id, + tool_name, + input, + .. + }, + ] => { + assert_eq!(request_id, "req-q1"); + assert_eq!(tool_name.as_deref(), Some("AskUserQuestion")); + let questions = input.as_ref().expect("AskUserQuestion carries input"); + assert_eq!( + questions["questions"][0]["question"], "Pick a color", + "the question text is projected, not dropped" + ); + } + other => panic!("expected one Permission, got {other:?}"), + } + } + + /// An ORDINARY tool permission (Bash) carries `tool_name` but NOT `input` — the + /// command body is TIO-13 sensitive and is deliberately dropped (the generic + /// allow/deny card needs no payload). Distinguishes it from AskUserQuestion. + #[test] + fn ordinary_tool_permission_drops_input_keeps_tool_name() { + let frame = serde_json::json!({ + "type": "control_request", + "request_id": "req-b1", + "request": { + "subtype": "can_use_tool", + "tool_name": "Bash", + "tool_use_id": "toolu-b1", + "input": { "command": "rm -rf /" } + } + }); + let events = ClaudeAdapter::parse_control_request(&frame); + match events.as_slice() { + [SessionEvent::Permission { tool_name, input, .. }] => { + assert_eq!(tool_name.as_deref(), Some("Bash")); + assert!( + input.is_none(), + "ordinary tool input (command body) is NOT projected (TIO-13)" + ); + } + other => panic!("expected one Permission, got {other:?}"), + } + } + + /// ede_diagnostic filter: claude's internal `[ede_diagnostic] …` debug template + /// in `errors[]` is dropped from `result_text` (it is NOT a user-readable error). + #[test] + fn parse_result_strips_ede_diagnostic_from_errors() { + // ONLY the diagnostic → after stripping, errors is empty + is_error → falls + // back to the structural `subtype` token, never the diagnostic string. + // + // The frame is the LIVE-CAPTURED result claude 2.1.185 emits on the + // cancel-before-output edge (fixture pins the verbatim shape so a future + // claude tag-change is caught here, not in production). The live oracle that + // proves this fixture is faithful is `live_cli_e2e.rs + // ::claude_live_cancel_before_output_never_leaks_ede_diagnostic`. + let raw = include_str!("../../tests/fixtures/claude_2.1.185_cancel_before_output_result.ndjson"); + let frame: serde_json::Value = serde_json::from_str(raw.trim()).expect("fixture is valid result JSON"); + let events = ClaudeAdapter::parse_result(&frame); + match events.first() { + Some(SessionEvent::TurnResult { + result_text, is_error, .. + }) => { + assert!(*is_error); + assert!( + !result_text.contains("ede_diagnostic"), + "the internal diagnostic must never reach result_text, got: {result_text}" + ); + assert_eq!( + result_text, "error_during_execution", + "stripped-empty errors + is_error falls back to the subtype token" + ); + } + other => panic!("expected a TurnResult, got {other:?}"), + } + } + + /// A genuine user-readable `errors[]` entry alongside the diagnostic survives — + /// only the diagnostic is filtered, not the real error. + #[test] + fn parse_result_keeps_real_error_drops_only_diagnostic() { + let frame = serde_json::json!({ + "type": "result", + "is_error": true, + "errors": ["[ede_diagnostic] result_type=user stop_reason=null", "rate limit exceeded"], + }); + let events = ClaudeAdapter::parse_result(&frame); + match events.first() { + Some(SessionEvent::TurnResult { result_text, .. }) => { + assert_eq!( + result_text, "rate limit exceeded", + "real error kept, diagnostic dropped" + ); + } + other => panic!("expected a TurnResult, got {other:?}"), + } + } + + /// ENUMERATION (parse_system subtype family, I8). `init` is silent; the network/ + /// compaction milestones normalize to the backend-neutral `Heartbeat` (liveness); + /// anything else is opaque `AdapterSpecific`. A typo in a match arm (e.g. + /// `api_retry`→`api_retrry`) would silently demote a Heartbeat to AdapterSpecific — + /// this table pins every documented subtype + the unknown fallthrough. + #[test] + fn parse_system_subtypes_map_to_heartbeat_or_opaque() { + let a = ClaudeAdapter::new(); + let sys = |subtype: &str| { + let v = serde_json::json!({ "type": "system", "subtype": subtype }); + a.parse_system(&v) + }; + + // init → no state signal (empty). + assert!(sys("init").is_empty(), "init carries no signal"); + + // network backoff + compaction window → Heartbeat (liveness). + for subtype in ["api_retry", "compact_boundary", "compacting"] { + assert!( + matches!(sys(subtype).as_slice(), [SessionEvent::Heartbeat]), + "`{subtype}` must normalize to a single Heartbeat" + ); + } + + // unknown subtype → opaque AdapterSpecific tagged `system/` (never lost, + // never a Heartbeat). Also the absent-subtype boundary → `system/`. + match sys("brand_new_milestone").as_slice() { + [SessionEvent::AdapterSpecific { tag, .. }] => { + assert_eq!( + tag, "system/brand_new_milestone", + "unknown subtype stays opaque with its tag" + ); + } + other => panic!("expected one AdapterSpecific, got {other:?}"), + } + let no_subtype = a.parse_system(&serde_json::json!({ "type": "system" })); + assert!( + matches!(no_subtype.as_slice(), [SessionEvent::AdapterSpecific { tag, .. }] if tag == "system/"), + "absent subtype → opaque `system/` (no panic), got {no_subtype:?}" + ); + } + + /// `control_cancel_request` (claude retracts a pending permission, §9.17) → a + /// `PermissionResolved{request_id, Tool}` so the requires-action sub-state clears + /// WITHOUT a user answer. This is a LIVE path (claude can withdraw a permission + /// mid-decision) that had no test. A missing/empty request_id degrades to opaque + /// AdapterSpecific (never a malformed PermissionResolved, never a panic). + #[test] + fn parse_control_cancel_request_resolves_permission_or_degrades() { + // Valid retraction → PermissionResolved. + let frame = serde_json::json!({ "type": "control_cancel_request", "request_id": "req-9" }); + match ClaudeAdapter::parse_control_cancel_request(&frame).as_slice() { + [SessionEvent::PermissionResolved { request_id, kind }] => { + assert_eq!(request_id, "req-9"); + assert_eq!( + *kind, + crate::event::PermissionKind::Tool, + "retraction is a TOOL approval" + ); + } + other => panic!("expected one PermissionResolved, got {other:?}"), + } + + // Missing AND empty request_id both degrade to opaque AdapterSpecific (the + // ref-count must NOT be decremented for an unidentifiable cancel). + for frame in [ + serde_json::json!({ "type": "control_cancel_request" }), + serde_json::json!({ "type": "control_cancel_request", "request_id": "" }), + ] { + match ClaudeAdapter::parse_control_cancel_request(&frame).as_slice() { + [SessionEvent::AdapterSpecific { tag, .. }] => { + assert_eq!(tag, "control_cancel_request", "unidentifiable cancel stays opaque"); + } + other => panic!("expected one AdapterSpecific, got {other:?}"), + } + } + } + + /// ENUMERATION (parse_result `result_text` fallback chain, §R10/self-heal). The + /// message text is chosen by a 4-arm precedence the ede + real-error tests only + /// drove partially. Pin EACH arm in isolation so a reorder/regression is caught: + /// arm1 `result` non-empty → use it verbatim (highest precedence); arm2 `result` + /// empty + `errors` set → join the (ede-filtered) errors; arm3 both empty + + /// is_error:true → fall back to the structural `subtype` token (so self-heal can + /// detect a stderr-only fail); arm4 both empty + is_error:false → STAY EMPTY (never + /// leak "success"; an empty success turn must read as EmptyTurn upstream). + #[test] + fn parse_result_text_fallback_chain_each_arm() { + let text_of = |v: &Value| match ClaudeAdapter::parse_result(v).into_iter().next() { + Some(SessionEvent::TurnResult { result_text, .. }) => result_text, + other => panic!("expected a TurnResult, got {other:?}"), + }; + + // Arm 1: `result` wins even when errors[] also present. + assert_eq!( + text_of(&serde_json::json!({ + "type": "result", "is_error": false, + "result": "the answer", "errors": ["ignored"] + })), + "the answer", + "arm1: non-empty result takes precedence over errors" + ); + // Arm 2: empty result, errors present → joined errors. + assert_eq!( + text_of(&serde_json::json!({ + "type": "result", "is_error": true, "result": "", + "errors": ["rate limit", "retry later"] + })), + "rate limit; retry later", + "arm2: errors joined when result empty" + ); + // Arm 3: both empty + is_error → subtype token (self-heal needs a non-empty msg). + assert_eq!( + text_of(&serde_json::json!({ + "type": "result", "is_error": true, "subtype": "error_during_execution" + })), + "error_during_execution", + "arm3: empty result+errors + is_error → subtype fallback" + ); + // Arm 4: both empty + SUCCESS → stays empty (never the subtype, never "success"). + assert_eq!( + text_of(&serde_json::json!({ + "type": "result", "is_error": false, "subtype": "success" + })), + "", + "arm4: success terminal keeps result_text empty (EmptyTurn needs it empty)" + ); + } + + /// PROPERTY (parse_result totality + is_error mirror — sibling of the + /// parse_assistant proptest). For ANY result frame with arbitrary is_error / + /// result / subtype / api_error_status, parse_result: + /// 1. NEVER panics (malformed input is data, not a crash — I4); + /// 2. emits exactly one TurnResult whose `is_error` MIRRORS the wire bit (default + /// false when absent) — the routing bit a SUCCESS-vs-error projection rides; + /// 3. NEVER leaks the diagnostic tag into result_text (the ede invariant, swept). + #[test] + fn prop_parse_result_mirrors_is_error_and_never_leaks_diagnostic() { + use proptest::prelude::*; + let is_err = prop_oneof![Just(None), Just(Some(true)), Just(Some(false))]; + let result = prop_oneof![Just(None), Just(Some(String::new())), "[a-z ]{0,12}".prop_map(Some)]; + let errors = prop::collection::vec( + prop_oneof![ + "[a-z ]{1,8}".prop_map(|s| s), + Just("[ede_diagnostic] result_type=user".to_string()), + ], + 0..4, + ); + proptest!(|(is_err in is_err, result in result, errors in errors)| { + let mut frame = serde_json::json!({ "type": "result", "subtype": "error_during_execution" }); + if let Some(e) = is_err { frame["is_error"] = serde_json::json!(e); } + if let Some(r) = result { frame["result"] = serde_json::json!(r); } + frame["errors"] = serde_json::json!(errors); + + let events = ClaudeAdapter::parse_result(&frame); // (1) must not panic + let tr = events.iter().find(|e| matches!(e, SessionEvent::TurnResult { .. })); + prop_assert!(tr.is_some(), "parse_result must emit a TurnResult"); + if let Some(SessionEvent::TurnResult { is_error, result_text, .. }) = tr { + // (2) is_error mirrors the wire bit (absent → false). + prop_assert_eq!(*is_error, is_err.unwrap_or(false), "is_error must mirror the wire"); + // (3) the diagnostic tag never reaches result_text. + prop_assert!( + !result_text.contains("[ede_diagnostic]"), + "result_text leaked the internal diagnostic: {:?}", result_text + ); + } + }); + } + + /// PROPERTY (parse_user totality + ToolResult is_error default — sibling proptest). + /// For ANY user frame whose `tool_result` blocks carry arbitrary is_error / content, + /// parse_user: + /// 1. NEVER panics; + /// 2. emits one ToolResult per block whose `is_error` MIRRORS the wire (absent → + /// false: a tool with no is_error is a SUCCESS, never silently failed). + #[test] + fn prop_parse_user_tool_result_mirrors_is_error_default_false() { + use proptest::prelude::*; + let is_err = prop_oneof![Just(None), Just(Some(true)), Just(Some(false))]; + let blocks = prop::collection::vec((is_err, "[a-z ]{0,10}"), 0..5); + proptest!(|(blocks in blocks)| { + let content: Vec = blocks + .iter() + .enumerate() + .map(|(i, (is_error, text))| { + let mut b = serde_json::json!({ + "type": "tool_result", + "tool_use_id": format!("tu-{i}"), + "content": text, + }); + if let Some(e) = is_error { b["is_error"] = serde_json::json!(e); } + b + }) + .collect(); + let frame = serde_json::json!({ + "type": "user", + "message": { "role": "user", "content": content } + }); + let a = ClaudeAdapter::new(); + let events = a.parse_user(&frame); // (1) must not panic + + let results: Vec<&SessionEvent> = + events.iter().filter(|e| matches!(e, SessionEvent::ToolResult { .. })).collect(); + prop_assert_eq!(results.len(), blocks.len(), "one ToolResult per tool_result block"); + for (ev, (wire_err, _)) in results.iter().zip(blocks.iter()) { + if let SessionEvent::ToolResult { is_error, .. } = ev { + prop_assert_eq!(*is_error, wire_err.unwrap_or(false), "is_error mirrors wire (absent→false)"); + } + } + }); + } + + #[test] + fn parse_tool_result_caps_oversized_text_on_char_boundary() { + // Regression: a tool_result whose text exceeds TOOL_TEXT_CAP where the byte + // at the cap splits a multi-byte UTF-8 char used to panic in `cap_text` + // (`String::truncate` requires a char boundary). The panic killed the reader + // task WITHOUT a terminal Detached, wedging the conversation at `pending`. + // Build a body of multi-byte CJK chars ('世' = 3 bytes) so the TOOL_TEXT_CAP-th + // byte is guaranteed mid-char (16384 % 3 != 0), then wrap it in a real frame. + let big = "世".repeat(TOOL_TEXT_CAP); // 3 * 16384 bytes ≫ cap, cap lands mid-char + let frame = serde_json::json!({ + "type": "user", + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "big1", "content": big} + ]} + }); + let a = ClaudeAdapter::new(); + match a.parse_user(&frame).as_slice() { + [SessionEvent::ToolResult { content, .. }] => match content.as_slice() { + [crate::event::ToolResultContent::Text(t)] => { + assert!(t.ends_with("…[truncated]"), "carries the truncation marker"); + let body = t.strip_suffix("…[truncated]").unwrap(); + assert!(body.len() <= TOOL_TEXT_CAP, "capped at or below TOOL_TEXT_CAP"); + // The real assertion is simply that we got here without panicking, + // AND that the cut landed on a char boundary (no partial '世'). + assert!( + body.chars().all(|c| c == '世'), + "truncation preserved whole chars, no split multi-byte char" + ); + } + other => panic!("expected one Text part, got {other:?}"), + }, + other => panic!("expected one ToolResult, got {other:?}"), + } + } +} diff --git a/crates/aionui-session/src/backend/acp_conn.rs b/crates/aionui-session/src/backend/acp_conn.rs new file mode 100644 index 000000000..877cf6489 --- /dev/null +++ b/crates/aionui-session/src/backend/acp_conn.rs @@ -0,0 +1,4031 @@ +//! 007 §C1 (ACP variant): `AcpConnection` / `AcpSessionBackend` — the third +//! `SessionBackend`, over the **Agent Client Protocol** (`session/*` JSON-RPC) +//! spoken by ACP CLIs / bridges (hermes, codex-acp, claude-acp, opencode, …). +//! +//! Like codex (`codex_conn`), this talks RAW JSON-RPC over the `AgentIo` byte +//! duplex — it does NOT use the `agent-client-protocol` SDK (that crate lives in +//! the upper `aionui-ai-agent` Domain crate; `aionui-session` must stay +//! transport-agnostic and SDK-free, so a backend here parses `serde_json::Value` +//! and matches on field strings). The reader-task / dispatch / capabilities +//! contract is identical in shape to codex; the wire dialect differs. +//! +//! THE ONE STRUCTURAL DIFFERENCE vs codex (and why this file exists): an ACP +//! turn's TERMINAL is the `session/prompt` REQUEST's RESPONSE (`{stopReason}`), +//! out-of-band of the `session/update` notification stream — there is NO +//! `turn/completed` notification (verified against real hermes/codex-acp wire). +//! So `dispatch(Send)` does NOT await the prompt response inline; it records +//! `rpc_id → client_msg_id` in `pending_prompts` and the reader claims that +//! response, synthesizes the `TurnResult` from its `stopReason`, and (because the +//! reader is the single ordered consumer of stdout) every `session/update` delta +//! has already been folded before the terminal — no cross-task race. This is the +//! codex GAP-A pending-sends pattern, reused for the terminal rather than the ack. +//! +//! Freeze-blocker parity with codex: +//! - A1 (anti-panic): `session/update` is matched on the `sessionUpdate` STRING, +//! never deserialized into a closed SDK enum. Unknown variant → `AdapterSpecific`. +//! - A2/A3 (reverse-RPC deadlock): the reader writes a JSON-RPC response for +//! every server-initiated request. `session/request_permission` surfaces as +//! `Permission` (answered by `dispatch(AnswerPermission)`); all other reverse +//! methods (`fs/*`, `terminal/*`, …) get an immediate `-32601` so the channel +//! never hangs (matches the SDK's own auto-reject of unhandled reverse RPC). + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use aionui_process::Spawner; +use futures_util::stream::{BoxStream, StreamExt}; +use serde_json::{Value, json}; +use tokio::sync::{Mutex, broadcast}; + +use super::suspend::{ProcHandle, SuspendController, spawn_idle_timer}; +use super::types::{ + Admission, BackendError, CancelTarget, Command, CommandReceipt, ContentBlock, PermissionDecision, SessionEnvelope, + SessionSpec, +}; +use super::{BackendConnection, SessionBackend, SessionConfig}; +use crate::adapter::AgentIo; +use crate::capability::{ + BlockSet, Capabilities, CapabilityTier, CommandSet, ModeInfo, ModelInfo, PromptAcceptedSource, SignalSet, +}; +use crate::event::{PermissionKind, SessionEvent, StopReason, SubagentKind, TruncationKind, TurnOutcome}; + +/// Connection-level factory for ACP. Holds the injected `Spawner` (S14 — never +/// raw-spawn) and the command that launches the ACP CLI/bridge. One process per +/// logical session (P1; ACP `session/new` could multiplex, a later refinement). +pub struct AcpConnection { + spawner: Arc, + /// The command that starts the ACP agent (e.g. `hermes acp`, the codex-acp + /// bridge binary). Connection-static; per-session `cwd`/`extra_args` come + /// from `SessionConfig` at `open_session`. + command: aionui_common::CommandSpec, +} + +impl AcpConnection { + pub fn new(spawner: Arc, command: aionui_common::CommandSpec) -> Self { + Self { spawner, command } + } +} + +#[async_trait::async_trait] +impl BackendConnection for AcpConnection { + async fn open_session( + &self, + spec: SessionSpec, + config: SessionConfig, + ) -> Result, BackendError> { + let logical_id = match &spec { + SessionSpec::Fresh { session_id } => session_id.clone(), + SessionSpec::Resume { session_id, .. } => session_id.clone(), + }; + + // Live spawn via the injected Spawner (records into the unified registry + // so the supervisor reaps the ACP child + its bun/node tree on a + // crash-restart — feature 006, automatic here because we go through + // Spawner/ManagedProcess rather than the legacy CliAgentProcess path). + let mut cmd = self.command.clone(); + cmd.args.extend(config.extra_args.iter().cloned()); + if let Some(cwd) = &config.cwd { + cmd.cwd = Some(cwd.clone()); + } + let proc = self + .spawner + .spawn(cmd, &[], "aionui-session") + .await + .map_err(|e| BackendError::Transport(format!("acp spawn failed: {e}")))?; + let io: Box = Box::new(crate::adapter::ManagedProcessIo::new(proc)); + // F-4 wake recipe: a Dormant→dispatch wake re-spawns the ACP CLI and + // replays the resume handshake (`session/load` against the bound sid). + // idle_ttl=None (default) → never suspends → identical to pre-F-4. + let wake = AcpWakeRecipe { + spawner: Some(self.spawner.clone()), + command: Some(self.command.clone()), + config: config.clone(), + }; + let mut backend = AcpSessionBackend::spawn_with_wake(logical_id, io, wake, config.idle_ttl_ms).await; + + // Seed the immutable capability snapshot's current model/mode from config + // (parity with codex/claude). SetModel/SetMode updates flow via + // ConfigChanged, not by mutating this open-time snapshot (§5.5). + backend.capabilities.current_model = config.model.clone(); + backend.capabilities.current_mode = config.mode.clone(); + *backend.current_model.lock().await = config.model.clone(); + + // JSON-RPC handshake (shared with wake_handle so the wire shape is in one + // place). Resume pre-seeds the acp_session_id binding so the first prompt + // has the sid without waiting on the load response. + let resume_sid = match &spec { + SessionSpec::Fresh { .. } => None, + // lost backend session → fresh under the same logical id (§4.1). + SessionSpec::Resume { backend_session_id, .. } => backend_session_id.clone(), + }; + backend.run_handshake(resume_sid.as_deref()).await?; + + let backend = Arc::new(backend); + // G6: the handshake above is fire-and-forget; the agent's OBSERVED mode/model + // lands asynchronously in the reader. Spawn a one-shot task that waits for it + // and re-aligns the agent to the DESIRED config (decision A — backend self-heals + // on open). Skip entirely when nothing is desired (no spurious set_* / no task). + if backend.wake.config.mode.is_some() || backend.wake.config.model.is_some() { + let reconcile = Arc::clone(&backend); + tokio::spawn(async move { reconcile.reconcile_startup_config().await }); + } + Ok(backend as Arc) + } + + async fn close_session(&self, _session_id: &str) -> Result<(), BackendError> { + // No connection-level per-session registry to release: `open_session` + // returns a self-owned `AcpSessionBackend` held by the conversation layer. + // Graceful close happens when the conversation drops its handle — + // `AcpSessionBackend::drop` aborts the reader and reaps the ACP subprocess + // (kill_on_drop). A mid-turn cancel is a separate, turn-scoped concern + // (`session/cancel` via `dispatch(Cancel)`), not a session teardown. + // Idempotent. + Ok(()) + } + + fn capabilities(&self) -> Capabilities { + acp_capabilities() + } +} + +/// `initialize` params (ACP). `protocolVersion` + client-side capabilities; we +/// advertise the reverse-RPC we actually handle (`session/request_permission`). +fn initialize_params() -> Value { + json!({ + "protocolVersion": 1, + "clientCapabilities": { "fs": { "readTextFile": false, "writeTextFile": false } } + }) +} + +/// `session/new` params: workspace cwd + the resolved MCP servers (Wave 0c). +fn new_session_params(config: &SessionConfig) -> Value { + json!({ + "cwd": config.cwd.clone().unwrap_or_else(|| ".".into()), + "mcpServers": build_mcp_servers(&config.init.mcp_servers), + }) +} + +/// `session/load` params: the ACP session id to re-attach + cwd + MCP servers. +/// +/// RESUME RE-INJECTS MCP (Wave 0c): a resumed ACP session re-runs `session/load`, +/// so the servers must be supplied here too — otherwise a resumed conversation +/// silently loses every MCP tool (the pre-0c `mcpServers: []` regression). +fn load_session_params(sid: &str, config: &SessionConfig) -> Value { + json!({ + "sessionId": sid, + "cwd": config.cwd.clone().unwrap_or_else(|| ".".into()), + "mcpServers": build_mcp_servers(&config.init.mcp_servers), + }) +} + +/// Serialize neutral [`McpServerSpec`]s into the ACP `session/new`|`session/load` +/// `mcpServers[]` JSON — BYTE-IDENTICAL to what the ACP SDK `McpServer` produces +/// (so the agent sees the same wire the legacy path sent): a Stdio entry is +/// untagged `{name, command, args, env:[{name,value}]}`; Http/Sse carry a `type` +/// discriminator + `{name, url, headers:[{name,value}]}`. Pure `serde_json`, no +/// ACP SDK (acp_conn stays SDK-free, §module header). env/headers are emitted in +/// the spec's order — the app boundary already sorts them (parity with the legacy +/// `session_server_to_sdk_mcp_server` deterministic sort) so hermetic assertions +/// are stable. +fn build_mcp_servers(servers: &[crate::backend::McpServerSpec]) -> Vec { + use crate::backend::McpTransport; + let kv = |pairs: &[(String, String)]| -> Vec { + pairs.iter().map(|(k, v)| json!({ "name": k, "value": v })).collect() + }; + servers + .iter() + .map(|s| match &s.transport { + McpTransport::Stdio { command, args, env } => json!({ + "name": s.name, + "command": command, + "args": args, + "env": kv(env), + }), + McpTransport::Http { url, headers } => json!({ + "type": "http", + "name": s.name, + "url": url, + "headers": kv(headers), + }), + McpTransport::Sse { url, headers } => json!({ + "type": "sse", + "name": s.name, + "url": url, + "headers": kv(headers), + }), + }) + .collect() +} + +/// ACP's declared capabilities (§C5.5 parity). tier=Parsed (ACP is fully +/// structured JSON-RPC). ACP supports per-turn permission, mode/model switching, +/// and (when advertised) auth; it has NO native steer / tool-scoped cancel / +/// rewind / checkpoint-list on the base wire, so those are advertised false and +/// `dispatch` rejects them with `CommandNotSupported` (the cap-behavior invariant). +pub fn acp_capabilities() -> Capabilities { + Capabilities { + tier: CapabilityTier::Parsed, + emits: SignalSet { + // ACP has no liveness heartbeat notification; the turn terminal is the + // prompt response (no idle-timeout in AionCore anyway, post-007). + heartbeat: false, + tool_lifecycle: true, + terminal_result: true, + }, + supported_commands: CommandSet { + // ACP base wire has no turn/steer-equivalent. + steer: false, + // No tool-scoped cancel (only whole-session `session/cancel`). + cancel_tool: false, + answer_permission: true, + // No standard mid-session auth reverse-RPC in the base ACP turn loop + // we drive (auth is a connection-level concern); advertise false. + answer_auth: false, + acknowledge: true, + set_mode: true, + set_model: true, + rewind: false, + list_checkpoints: false, + query_session_info: false, + }, + prompt_blocks: BlockSet { + // ACP baseline mandates Text + ResourceLink; image/audio are optional + // (PromptCapabilities). We advertise the safe baseline + image. + text: true, + image: true, + audio: false, + resource: true, + at_mention: false, + }, + // ACP's `session/prompt` response is BOTH the accept ack and the terminal + // (one return). We synthesize PromptAccepted optimistically when the prompt + // is written to the wire, so the conversation's pending queue drains + // immediately rather than only at turn end. + prompt_accepted: PromptAcceptedSource::Synthesized, + available_models: Vec::new(), + available_modes: Vec::new(), + current_model: None, + current_mode: None, + current_effort: None, + auth_methods: Vec::new(), + // 009 R2: ACP is one session/prompt at a time — no proactive next-turn + // input path from the conv layer. can_queue degrades to false (= can_send). + accepts_proactive_input: false, + // #101: static default empty; filled from the `available_commands_update` + // session/update (capabilities() merges the discovered set on read). + slash_commands: Vec::new(), + } +} + +/// Per-session ACP handle. `&self`-concurrent (stdin write behind a Mutex). +pub struct AcpSessionBackend { + session_id: String, + /// Base capability snapshot: the static `acp_capabilities()` + open-time + /// current_model/current_mode (seeded mutably in `open_session` before the + /// backend is Arc'd, parity with codex/claude). Immutable after open; the + /// reader-discovered available models/modes are merged in `capabilities()`. + capabilities: Capabilities, + /// Reader-filled available models/modes (from the `session/new`|`load` + /// response). Behind a sync Mutex so the sync `capabilities()` merges them + /// without awaiting (the static base cannot carry per-session discovery). + discovered: Arc>, + rpc_id: AtomicU64, + /// Live turn epoch (bumped on dispatch(Send), read by the reader to stamp). + turn_gen: Arc, + stdin: Arc>>, + event_tx: broadcast::Sender, + /// F-4 self-suspend controller owning the live `{reader, io}` pair. ACP CLIs/ + /// bridges are persistent (stdout never EOFs mid-session), so the reader is + /// aborted on suspend AND on Drop (`abort_on_drop`, M5) to reap the child. When + /// idle_ttl=None (default) the slot stays Active for life — pre-F-4 parity. + suspend: Arc, + /// Per-backend idle timer (Some only when idle_ttl is set). Aborted on Drop. + idle_timer: Option>, + /// What a Dormant→dispatch wake needs to re-spawn the ACP CLI + replay the + /// resume handshake (`session/load` against the bound acp_session_id). + wake: AcpWakeRecipe, + /// Shared reader inputs, cloned into the open-time reader AND every post-wake + /// reader so they drain into the same broadcast/atomics/bindings. + reader_state: AcpReaderState, + /// F-4 turn-active flag (shared with the reader via `reader_state`): set on + /// dispatch(Send), cleared by the reader at the terminal. The idle timer reads + /// it so a streaming turn is never suspended mid-flight. + turn_in_flight: Arc, + /// Logical session_id ← ACP backend session id binding. Filled by the reader + /// when it claims the `session/new` response (which carries `sessionId`) or the + /// `session/load` response (which does NOT — the sid is taken from + /// `pending_resume_sid`). NOT pre-seeded on Resume: the ACP spec requires the + /// client to wait for the full `session/load` response before prompting (the + /// agent replays history via `session/update` first, then responds), so binding + /// only on the response makes `bound_session()` gate the first prompt correctly. + /// Never escapes upward except via BackendBound (the resume anchor). Two-id (§4.1). + acp_session_id: Arc>>, + /// The resume sid an in-flight `session/load` is re-attaching to. Set in + /// `run_handshake`'s Resume branch (instead of pre-seeding `acp_session_id`) and + /// consumed by the reader when the load RESPONSE arrives: `session/load` returns + /// no `sessionId`, so the reader binds `acp_session_id` to THIS value on success. + pending_resume_sid: Arc>>, + /// The current model id (for SetMode/SetModel tracking + ConfigChanged). + current_model: Arc>>, + /// rpc_id of the in-flight `session/new`|`session/load` request, so the reader + /// claims its response → binds the ACP session id + fills discovery. + pending_open: Arc>>, + /// rpc_id of the in-flight `initialize` request, so the reader claims its + /// RESPONSE and parses `authMethods[]` into `Discovered.auth_methods` (the auth + /// capability the agent advertises at connect — hermes carries it, claude does + /// not). Symmetric with `pending_open`. + pending_init: Arc>>, + /// rpc_id → client_msg_id for in-flight `session/prompt` requests. The reader + /// claims the response, reads its `stopReason`, and synthesizes the terminal + /// `TurnResult` (THE ACP-specific terminal path — see the module header). + pending_prompts: Arc>>, + /// rpc_id → `"mode→"` / `"model→"` label for in-flight `session/set_mode` / + /// `session/set_model` requests. The reader claims the response: a JSON-RPC ERROR + /// (e.g. opencode `-32602 model not found`) is surfaced as a `Notice{Warning}` + + /// error log instead of being silently dropped; a SUCCESS (incl opencode's empty + /// `{}`) drives `ConfigChanged` with the labelled value ITSELF (the response is the + /// authoritative "applied" signal) and advances the discovered current_mode/model — + /// it does NOT wait for an echo notification, because opencode 1.16.2 sends none + /// (the "set doesn't stick" prod bug). claude-acp's later `config_option_update` + /// echo carries the same value (reducer idempotent) — the two paths never conflict. + pending_set: Arc>>, + /// request_id → the options the agent OFFERED on a `session/request_permission` + /// (each `(optionId, kind)`, kind ∈ allow_once/allow_always/reject_once/ + /// reject_always). The response MUST echo one of THOSE real optionIds — we used + /// to hardcode "allow_once"/"cancelled", which bridges reject (→ tool silently + /// denied) or mis-route (deny→client-abort). dispatch(AnswerPermission) looks up + /// this set and picks the optionId matching the decision's kind. + pending_perm_options: PendingPermOptions, + /// Wave 0c-F: the composed first-message preamble (`[Assistant Rules]` block + /// from `SessionConfig.init.preset_context`). ACP has no system-prompt wire + /// field, so the preset is delivered by prepending this to the FIRST + /// `session/prompt`. `take()`-drained on the first `dispatch(Send)` so it is + /// applied exactly once; `None` = no preset (the pre-0c first prompt unchanged). + pending_preamble: Mutex>, +} + +/// Per-request_id offered permission options: `(optionId, kind)` pairs the agent +/// sent on `session/request_permission`, so the answer echoes a REAL optionId by kind. +type PendingPermOptions = Arc>>>; + +/// What a pending `session/prompt` carries so the reader can synthesize the +/// terminal when its response lands. +#[derive(Clone)] +struct PendingPrompt { + /// The turn epoch this prompt opened (stamped on the synthesized TurnResult). + turn_gen: u64, +} + +/// Reader-discovered models/modes (from the `session/new`|`load` response). +/// `capabilities()` merges these into the returned snapshot. +#[derive(Default, Clone)] +struct Discovered { + models: Vec, + modes: Vec, + current_model: Option, + current_mode: Option, + /// Auth method ids advertised in the `initialize` RESPONSE (`authMethods[].id`). + /// hermes returns e.g. `[bedrock, hermes-setup]`; claude ACP returns none. + /// Non-empty ⇒ `capabilities().auth_methods` + `answer_auth` cap flip true. + auth_methods: Vec, + /// #101: slash commands from the `available_commands_update` session/update + /// (`update.availableCommands[{name, description, input?}]` — wire-pinned from + /// hermes + claude-acp captures). `capabilities()` merges them on read. + slash_commands: Vec, + /// G4: the ids of GENERIC config options the agent advertised in the + /// `session/new|load` response `configOptions[]` (and `config_option_update` + /// notifications) — e.g. claude-acp `effort` (category `thought_level`). `mode`/ + /// `model` are EXCLUDED (they have dedicated set_mode/set_model arms). A + /// non-empty set is what gates `Command::SetConfigOption` (advertised ⟺ settable), + /// keeping the cap-behavior invariant honest. Ids only — the rich catalog + /// (values/labels) surfaces to the UI via the existing modes/models path; this + /// is purely the dispatch allowlist. + config_options: Vec, +} + +impl AcpSessionBackend { + /// Test-support seam: build over an injected `AgentIo` replaying an ACP + /// JSON-RPC fixture WITHOUT spawning a real CLI — proves the + /// parse/reverse-RPC/dispatch contract end-to-end. + #[cfg(any(test, feature = "test-support"))] + pub async fn build_with_io(session_id: impl Into, io: Box) -> Self { + Self::spawn(session_id.into(), io).await + } + + /// Test-support seam: build a SUSPENDABLE backend with a caller-supplied + /// `Spawner` + ACP `command` (to observe the wake re-spawn) and an `idle_ttl_ms`. + /// Lets a test drive the suspend→wake path: the idle slot suspends, and the + /// next dispatch wakes via the supplied spawner (asserting the resume re-spawn). + #[cfg(any(test, feature = "test-support"))] + pub async fn build_with_io_suspending( + session_id: impl Into, + io: Box, + spawner: Arc, + command: aionui_common::CommandSpec, + idle_ttl_ms: i64, + ) -> Self { + let wake = AcpWakeRecipe { + spawner: Some(spawner), + command: Some(command), + config: SessionConfig::default(), + }; + Self::spawn_with_wake(session_id.into(), io, wake, Some(idle_ttl_ms)).await + } + + /// Test-support seam: pre-bind the ACP session id (what the `session/new` + /// response does on the live path). `open_session` runs the handshake; + /// `build_with_io` skips it, so a hermetic `dispatch(Send)` / terminal test + /// uses this to satisfy `bound_session()` without a live process. + #[cfg(any(test, feature = "test-support"))] + pub async fn bind_for_test(&self, acp_session_id: impl Into) { + *self.acp_session_id.lock().await = Some(acp_session_id.into()); + } + + /// Test-support seam: register a pending `session/new`|`session/load` rpc id + /// so a hermetic fixture can replay that response and exercise the reader's + /// open-response path (bind ACP sid → `BackendBound{Some}` + discovery fill). + /// On the live path `open_session` sets this; `build_with_io` skips it. + #[cfg(any(test, feature = "test-support"))] + pub async fn set_pending_open_for_test(&self, rpc_id: u64) { + *self.pending_open.lock().await = Some(rpc_id); + } + + /// Test-support seam: register a pending `initialize` rpc id so a hermetic + /// fixture can replay that response and exercise the reader's auth-discovery + /// path (parse `authMethods[]` → `Discovered.auth_methods` → capabilities()). + /// On the live path `run_handshake` sets this; `build_with_io` skips it. + #[cfg(any(test, feature = "test-support"))] + pub async fn set_pending_init_for_test(&self, rpc_id: u64) { + *self.pending_init.lock().await = Some(rpc_id); + } + + /// Test-support seam: stash the in-flight resume sid, marking the reader as + /// inside a `session/load` replay window (what `run_handshake`'s Resume branch + /// sets). Lets a hermetic fixture prove the reader SUPPRESSES the historical + /// `session/update` replay from the UI event stream while a resume load is + /// in flight. `build_with_io` skips the handshake, so a test seeds it here. + #[cfg(any(test, feature = "test-support"))] + pub async fn set_pending_resume_sid_for_test(&self, sid: impl Into) { + *self.pending_resume_sid.lock().await = Some(sid.into()); + } + + /// Test-support seam: register a pending `session/set_model`|`session/set_mode` + /// rpc id + label so a hermetic fixture can replay an error response and assert + /// the reader surfaces a `Notice` (not a silent drop). On the live path + /// `dispatch(SetModel/SetMode)` registers it. + #[cfg(any(test, feature = "test-support"))] + pub async fn set_pending_set_for_test(&self, rpc_id: u64, label: impl Into) { + self.pending_set.lock().await.insert(rpc_id, label.into()); + } + + /// Test-support seam: set the Wave 0c-F first-message preamble (the production + /// path composes it from `SessionConfig.init.preset_context` in + /// `spawn_with_wake`; `build_with_io` uses an inert config, so a hermetic test + /// seeds it here to exercise the first-prompt prepend). + #[cfg(any(test, feature = "test-support"))] + pub async fn set_pending_preamble_for_test(&self, preamble: impl Into) { + *self.pending_preamble.lock().await = Some(preamble.into()); + } + + #[cfg(any(test, feature = "test-support"))] + async fn spawn(session_id: String, io: Box) -> Self { + Self::spawn_with_wake(session_id, io, AcpWakeRecipe::inert(), None).await + } + + /// G6 test seam: build a backend whose wake recipe carries DESIRED mode/model + /// (what the conversation wanted), so `reconcile_startup_config` has something + /// to align the agent's OBSERVED values to. No spawner (never suspends). + #[cfg(any(test, feature = "test-support"))] + pub async fn build_with_io_and_desired( + session_id: impl Into, + io: Box, + desired_mode: Option, + desired_model: Option, + ) -> Self { + let wake = AcpWakeRecipe { + spawner: None, + command: None, + config: SessionConfig { + mode: desired_mode, + model: desired_model, + ..SessionConfig::default() + }, + }; + Self::spawn_with_wake(session_id.into(), io, wake, None).await + } + + /// G6 test seam: seed the reader-discovered (OBSERVED) current mode/model, as if + /// the `session/new|load` response had already been parsed, so a test can drive + /// `reconcile_startup_config` without a live handshake round-trip. + #[cfg(any(test, feature = "test-support"))] + pub async fn seed_observed_for_test(&self, mode: Option, model: Option) { + let mut disc = self.discovered.lock().unwrap_or_else(|e| e.into_inner()); + disc.current_mode = mode; + disc.current_model = model; + } + + /// Spawn + (optionally) enable F-4 idle self-suspend. `wake` carries what a + /// Dormant→dispatch wake needs (the ACP command + spawner + config); + /// idle_ttl=None = never suspend (the `spawn` default). + async fn spawn_with_wake( + session_id: String, + io: Box, + wake: AcpWakeRecipe, + idle_ttl_ms: Option, + ) -> Self { + let io: Arc = Arc::from(io); + let turn_gen = Arc::new(AtomicU64::new(0)); + let acp_session_id = Arc::new(Mutex::new(None)); + let pending_resume_sid = Arc::new(Mutex::new(None)); + let current_model = Arc::new(Mutex::new(None)); + let pending_open = Arc::new(Mutex::new(None)); + let pending_init = Arc::new(Mutex::new(None)); + let pending_prompts = Arc::new(Mutex::new(HashMap::new())); + let pending_set = Arc::new(Mutex::new(HashMap::new())); + let pending_perm_options = Arc::new(Mutex::new(HashMap::new())); + let discovered = Arc::new(std::sync::Mutex::new(Discovered::default())); + let turn_in_flight = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (event_tx, _) = broadcast::channel(1024); + + let (stdin, stdout) = match io.take_stdio().await { + Some((stdin, stdout)) => (Some(stdin), Some(stdout)), + None => (None, None), + }; + let stdin = Arc::new(Mutex::new(stdin)); + + let reader_state = AcpReaderState { + session_id: session_id.clone(), + turn_gen: turn_gen.clone(), + event_tx: event_tx.clone(), + acp_session_id: acp_session_id.clone(), + pending_resume_sid: pending_resume_sid.clone(), + current_model: current_model.clone(), + pending_open: pending_open.clone(), + pending_init: pending_init.clone(), + pending_prompts: pending_prompts.clone(), + pending_set: pending_set.clone(), + pending_perm_options: pending_perm_options.clone(), + discovered: discovered.clone(), + stdin: stdin.clone(), + turn_in_flight: turn_in_flight.clone(), + }; + let reader = start_acp_reader(&reader_state, stdout, io.clone()); + + // Wave 0c-F: compose the first-message preamble from the preset context. + // ACP has no system-prompt field, so a non-empty preset is delivered by + // prepending an `[Assistant Rules]` block to the first prompt (light-mode + // format, byte-identical to the legacy first_message_injector). + let pending_preamble = Mutex::new( + wake.config + .init + .preset_context + .as_deref() + .filter(|s| !s.is_empty()) + .map(|ctx| format!("[Assistant Rules]\n{ctx}\n[/Assistant Rules]")), + ); + + let suspend = Arc::new(SuspendController::active( + ProcHandle::new(reader, io), + idle_ttl_ms, + aionui_common::now_ms(), + )); + let idle_timer = { + let tif = turn_in_flight.clone(); + // 009 R6 cleanup path 3: emit BackendSuspended on idle-reap → orchestrator + // clears the workflow_roster (a running workflow's task_notification will + // never arrive once the process is reaped). + let etx = event_tx.clone(); + let sid = session_id.clone(); + let tgen = turn_gen.clone(); + spawn_idle_timer( + &suspend, + idle_check_interval_ms(idle_ttl_ms), + aionui_common::now_ms, + move || tif.load(std::sync::atomic::Ordering::SeqCst), + move || { + let _ = etx.send(SessionEnvelope { + session_id: sid.clone(), + turn_gen: tgen.load(std::sync::atomic::Ordering::SeqCst), + event: SessionEvent::BackendSuspended, + }); + }, + ) + }; + + Self { + session_id, + capabilities: acp_capabilities(), + discovered, + rpc_id: AtomicU64::new(0), + turn_gen, + stdin, + event_tx, + suspend, + idle_timer, + wake, + reader_state, + turn_in_flight, + acp_session_id, + pending_resume_sid, + pending_perm_options, + current_model, + pending_open, + pending_init, + pending_prompts, + pending_set, + pending_preamble, + } + } + + /// Write one JSON-RPC frame (request or response) as a single line. + async fn write_frame(&self, frame: Value) -> Result<(), BackendError> { + write_frame_to(&self.stdin, frame).await + } + + fn next_rpc_id(&self) -> u64 { + self.rpc_id.fetch_add(1, Ordering::SeqCst) + 1 + } + + /// Resolve the bound ACP session id, waiting briefly for the session/new|load + /// response the reader binds. Every `session/prompt` + `session/set_*` needs it. + async fn bound_session(&self) -> Result { + // Regression-by-rewrite (audit + codex-500 twin): this was a hardcoded + // 40×50ms=2s busy-poll returning a bare Transport → opaque 500 — the IDENTICAL + // bug codex's bound_thread had, and a downgrade from legacy ACP's 30s + // (aionui-agent-rest INIT_TIMEOUT_SECS). A cold start / untrusted project slows + // agent init past 2s → 500. Use the shared handshake budget (30s, env-overridable) + // and the RETRYABLE HandshakeTimeout so the user sees "agent starting, retry". + self.bound_session_within(super::handshake_budget()).await + } + + /// Inner (test seam, mirrors codex bound_thread_within): poll for the acp_session_id + /// binding within `budget`; tests pass a tiny budget to exercise the timeout branch. + async fn bound_session_within(&self, budget: std::time::Duration) -> Result { + let polls = (budget.as_millis() / 50).max(1) as u64; + for _ in 0..polls { + if let Some(sid) = self.acp_session_id.lock().await.clone() { + return Ok(sid); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + Err(BackendError::HandshakeTimeout( + "acp sessionId not bound (session/new response not received within handshake budget)".into(), + )) + } + + /// G4: did the agent advertise a GENERIC config option with this id (from the + /// discovered `configOptions[]`)? Gates `Command::SetConfigOption` so the + /// cap-behavior invariant holds (advertised ⟺ settable; an agent with no generic + /// options rejects). `mode`/`model` are intentionally NOT here — they route to + /// the dedicated set_mode/set_model arms before reaching SetConfigOption. + fn has_config_option(&self, option_id: &str) -> bool { + self.discovered + .lock() + .unwrap_or_else(|e| e.into_inner()) + .config_options + .iter() + .any(|id| id == option_id) + } + + /// G6 (imperative shell): after `open_session`'s fire-and-forget handshake, the + /// reader parses the `session/new|load` response ASYNCHRONOUSLY into `discovered` + /// (the agent's OBSERVED current mode/model — which is the agent's OWN default on + /// a Resume, not necessarily what this conversation wanted). This one-shot task + /// waits for that response to land, then re-aligns the agent to the DESIRED + /// mode/model (the conversation's config) by dispatching the EXISTING, tested + /// SetMode/SetModel write-half. Decision A (backend self-heals on open) — the app + /// stays zero-change; the pure `reconcile_plan` owns the diff logic. Best-effort: + /// any dispatch error is logged, never fatal (a turn can still proceed). + async fn reconcile_startup_config(self: &Arc) { + // Wait for the open-response to be parsed: the reader clears `pending_open` + // once it consumes the session/new|load result (same bound as bound_session). + let mut parsed = false; + for _ in 0..40 { + if self.pending_open.lock().await.is_none() { + parsed = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + if !parsed { + // No open-response within the window — nothing observed to reconcile + // against; a later turn surfaces any real failure. Stay silent (no spam). + return; + } + let caps = self.capabilities(); // merges discovered (observed) over the seed + let desired_mode = self.wake.config.mode.clone(); + let desired_model = self.wake.config.model.clone(); + // `capabilities()` reflects the OBSERVED current_* once discovered is filled, + // but the open-time seed also wrote desired into the base snapshot — so read + // observed from `discovered` directly to avoid comparing desired against itself. + let (obs_mode, obs_model) = { + let disc = self.discovered.lock().unwrap_or_else(|e| e.into_inner()); + (disc.current_mode.clone(), disc.current_model.clone()) + }; + let plan = reconcile_plan( + caps.supported_commands.set_mode, + desired_mode.as_deref(), + obs_mode.as_deref(), + caps.supported_commands.set_model, + desired_model.as_deref(), + obs_model.as_deref(), + ); + for cmd in plan { + let what = match &cmd { + Command::SetMode { mode } => format!("mode→{mode}"), + Command::SetModel { model } => format!("model→{model}"), + _ => String::new(), + }; + if let Err(e) = self.dispatch(cmd).await { + tracing::warn!(session_id = %self.session_id, %what, error = %e, "G6 startup reconcile dispatch failed"); + } else { + tracing::info!(session_id = %self.session_id, %what, "G6: reconciled resumed session to desired config"); + } + } + } + + /// Replay the JSON-RPC handshake over the (already-connected) stdin: an + /// `initialize`, then `session/load` (Resume — pre-seeds the acp_session_id + /// binding) or `session/new` (Fresh / lost-Resume). Capability discovery rides + /// the session/new|load RESPONSE (the reader claims it by the `pending_open` + /// rpc id). Shared by `open_session` (initial open) and `wake_handle` (idle-wake + /// re-attach), so the wire shape lives in one place. + async fn run_handshake(&self, resume_sid: Option<&str>) -> Result<(), BackendError> { + let init_id = self.next_rpc_id(); + // Register the initialize rpc id so the reader claims its RESPONSE and parses + // `authMethods[]` (the advertised auth capability) into Discovered. + self.pending_init.lock().await.replace(init_id); + self.write_frame(json!({ + "jsonrpc": "2.0", "id": init_id, "method": "initialize", + "params": initialize_params() + })) + .await?; + let id = self.next_rpc_id(); + self.pending_open.lock().await.replace(id); + match resume_sid { + Some(sid) => { + // Do NOT pre-seed `acp_session_id` here. The ACP spec requires the + // client to wait for the full `session/load` response (the agent + // replays history via `session/update` first) before prompting; + // pre-seeding would let `bound_session()` release the first prompt + // before opencode has loaded the session → `-32602 session not found`. + // Stash the sid so the reader binds it when the load RESPONSE arrives + // (session/load returns no `sessionId` of its own). + *self.pending_resume_sid.lock().await = Some(sid.to_string()); + // Clear any existing binding so `bound_session()` blocks until THIS + // load completes. At first open it is already None; on an idle-wake + // re-attach (wake_handle shares run_handshake) it holds the pre-suspend + // sid — clearing it makes the post-wake gate wait for the fresh load + // too, not release a prompt against a not-yet-reloaded session. + *self.acp_session_id.lock().await = None; + self.write_frame(json!({ + "jsonrpc": "2.0", "id": id, "method": "session/load", + "params": load_session_params(sid, &self.wake.config) + })) + .await?; + } + None => { + self.write_frame(json!({ + "jsonrpc": "2.0", "id": id, "method": "session/new", + "params": new_session_params(&self.wake.config) + })) + .await?; + } + } + Ok(()) + } + + /// Wake from Dormant: re-spawn the ACP CLI/bridge, re-take its stdio, swap the + /// fresh stdin into the retained slot, start a new reader on the SAME + /// event_tx/turn_gen/bindings, and replay the resume handshake against the + /// bound acp_session_id (the resume anchor that survived the suspend). Only + /// reached when idle_ttl is set AND the slot was suspended (a test backend has + /// no spawner → `inert()` → never enabled). + async fn wake_handle(&self) -> Result { + let (Some(spawner), Some(command)) = (self.wake.spawner.as_ref(), self.wake.command.as_ref()) else { + return Err(BackendError::Transport( + "acp wake: no spawner/command (suspension not enabled)".into(), + )); + }; + let mut cmd = command.clone(); + cmd.args.extend(self.wake.config.extra_args.iter().cloned()); + if let Some(cwd) = &self.wake.config.cwd { + cmd.cwd = Some(cwd.clone()); + } + let proc = spawner + .spawn(cmd, &[], "aionui-session") + .await + .map_err(|e| BackendError::Transport(format!("acp resume-spawn failed: {e}")))?; + let io: Arc = Arc::from(Box::new(crate::adapter::ManagedProcessIo::new(proc)) as Box); + let (stdin, stdout) = match io.take_stdio().await { + Some((stdin, stdout)) => (Some(stdin), Some(stdout)), + None => (None, None), + }; + *self.stdin.lock().await = stdin; + let reader = start_acp_reader(&self.reader_state, stdout, io.clone()); + // Replay the handshake against the bound sid (resume re-attach via + // session/load). The binding survived the suspend. On a handshake failure, + // abort the just-started reader so its AgentIo clone releases and the + // freshly-spawned child is reaped (kill_on_drop) — else it leaks (the + // controller never takes ownership of a failed wake's handle). + let resume_sid = self.acp_session_id.lock().await.clone(); + if let Err(e) = self.run_handshake(resume_sid.as_deref()).await { + reader.abort(); + return Err(e); + } + Ok(ProcHandle::new(reader, io)) + } +} + +/// Shared frame writer (used by the backend + the reader's reverse-RPC responses, +/// both behind the same stdin Mutex so writes never interleave). +async fn write_frame_to( + stdin: &Arc>>, + frame: Value, +) -> Result<(), BackendError> { + let mut guard = stdin.lock().await; + let w = guard + .as_mut() + .ok_or_else(|| BackendError::Transport("acp stdin unavailable".into()))?; + let mut line = serde_json::to_vec(&frame).map_err(|e| BackendError::Transport(e.to_string()))?; + line.push(b'\n'); + use tokio::io::AsyncWriteExt; + w.write_all(&line) + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + w.flush().await.map_err(|e| BackendError::Transport(e.to_string()))?; + Ok(()) +} + +/// Reader-task context (grouped to avoid a too-many-arguments fn). +struct ReaderCtx { + session_id: String, + stdout: Option, + io: Arc, + turn_gen: Arc, + event_tx: broadcast::Sender, + acp_session_id: Arc>>, + pending_resume_sid: Arc>>, + current_model: Arc>>, + pending_open: Arc>>, + pending_init: Arc>>, + pending_prompts: Arc>>, + pending_set: Arc>>, + pending_perm_options: PendingPermOptions, + discovered: Arc>, + stdin: Arc>>, + turn_in_flight: Arc, +} + +/// The process-independent share of `ReaderCtx`: everything cloned into the +/// open-time reader AND every post-wake reader (the per-process `stdout`/`io` are +/// supplied per spawn). The acp_session_id binding survives a suspend, so a wake +/// re-attaches via `session/load`. +#[derive(Clone)] +struct AcpReaderState { + session_id: String, + turn_gen: Arc, + event_tx: broadcast::Sender, + acp_session_id: Arc>>, + pending_resume_sid: Arc>>, + current_model: Arc>>, + pending_open: Arc>>, + pending_init: Arc>>, + pending_prompts: Arc>>, + pending_set: Arc>>, + pending_perm_options: PendingPermOptions, + discovered: Arc>, + stdin: Arc>>, + /// F-4 turn-active flag: set on dispatch(Send), cleared by the reader at a turn + /// terminal (synthesized TurnResult / Detached). The idle timer reads it so a + /// streaming turn is never suspended mid-flight. + turn_in_flight: Arc, +} + +/// Spawn an ACP JSON-RPC reader over `stdout`/`io` using the shared state. Used +/// both at open (`spawn_with_wake`) and on every idle-wake (`wake_handle`). +fn start_acp_reader( + state: &AcpReaderState, + stdout: Option, + io: Arc, +) -> tokio::task::JoinHandle<()> { + let state = state.clone(); + tokio::spawn(async move { + reader_task(ReaderCtx { + session_id: state.session_id, + stdout, + io, + turn_gen: state.turn_gen, + event_tx: state.event_tx, + acp_session_id: state.acp_session_id, + pending_resume_sid: state.pending_resume_sid, + current_model: state.current_model, + pending_open: state.pending_open, + pending_init: state.pending_init, + pending_prompts: state.pending_prompts, + pending_set: state.pending_set, + pending_perm_options: state.pending_perm_options, + discovered: state.discovered, + stdin: state.stdin, + turn_in_flight: state.turn_in_flight, + }) + .await; + }) +} + +/// What `AcpSessionBackend::wake_handle` needs to re-spawn the ACP CLI/bridge and +/// replay the resume handshake. `inert()` (no spawner) is used for test-built +/// backends, which never suspend, so it is never consulted. +struct AcpWakeRecipe { + spawner: Option>, + command: Option, + config: SessionConfig, +} + +impl AcpWakeRecipe { + #[cfg(any(test, feature = "test-support"))] + fn inert() -> Self { + Self { + spawner: None, + command: None, + config: SessionConfig::default(), + } + } +} + +/// The idle-check cadence for a ttl: poll at ~ttl/4 (bounded 1s..=30s). Only +/// consulted when idle_ttl is Some (else no timer is spawned). +fn idle_check_interval_ms(idle_ttl_ms: Option) -> u64 { + match idle_ttl_ms { + Some(ttl) => ((ttl / 4).clamp(1_000, 30_000)) as u64, + None => 30_000, + } +} + +/// The long-lived JSON-RPC reader: each line is a `session/update` notification, +/// a `session/request_permission` (or other) reverse-RPC, or a response to one of +/// our requests (`session/new`|`load`|`prompt`|`set_*`). Single ordered consumer +/// of stdout, so deltas always fold before the prompt response (the terminal). +async fn reader_task(ctx: ReaderCtx) { + use tokio::io::{AsyncBufReadExt, BufReader}; + let ReaderCtx { + session_id, + stdout, + io, + turn_gen, + event_tx, + acp_session_id, + pending_resume_sid, + current_model, + pending_open, + pending_init, + pending_prompts, + pending_set, + pending_perm_options, + discovered, + stdin, + turn_in_flight, + } = ctx; + + let Some(stdout) = stdout else { + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + // Startup double-take guard: stdio was never available, so there is + // no meaningful stderr to attribute — G2 summary stays None. + SessionEvent::Detached { + exit: None, + redacted_summary: None, + }, + ); + return; + }; + + let mut lines = BufReader::new(stdout).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + let Ok(frame): Result = serde_json::from_str(line) else { + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::AdapterSpecific { + tag: "acp_unparseable".into(), + payload: json!({ "raw": line }), + }, + ); + continue; + }; + + let method = frame.get("method").and_then(Value::as_str); + let has_id = frame.get("id").is_some(); + match (method, has_id) { + // reverse-RPC (server-initiated request): permission → surface; + // everything else → -32601 unblock (A2/A3). + (Some(m), true) => { + handle_reverse_rpc( + m, + &frame, + &session_id, + &turn_gen, + &event_tx, + &stdin, + &pending_perm_options, + ) + .await; + } + // notification (`session/update` + others) → SessionEvent(s). + (Some(m), false) => { + let cur = turn_gen.load(Ordering::SeqCst); + let params = frame.get("params").unwrap_or(&Value::Null); + if m == "session/update" { + // A resumed session replays its FULL history as `session/update` + // notifications between the `session/load` request and its + // RESPONSE (ACP spec ordering — see run_handshake). Those events + // are historical: conversation_blocks is the SSOT and the frontend + // renders history from it, so broadcasting the replay would + // duplicate UI blocks AND spuriously light the turn-active + // indicator (a resumed conv has no live turn — e.g. warmup on a + // model change). Mirror the agent-rest `replay_suppression` guard: + // still run map_update for its metadata side-effects (slash-command + // / config-option catalog land in `discovered`), but suppress the + // UI emit while the resume load is in flight. The window is + // resume-only (`pending_resume_sid` is set on session/load, never on + // a fresh session/new — which has no history to replay) and holds + // only replay (bound_session() blocks the first prompt until the + // load RESPONSE takes the sid), so no live turn output is dropped. + let replaying = pending_resume_sid.lock().await.is_some(); + let events = map_update(params, ¤t_model, &discovered).await; + if !replaying { + for ev in events { + emit(&event_tx, &session_id, cur, ev); + } + } + } + // Other notifications are FSM-orthogonal / unknown → opaque. + } + // response to one of OUR requests (id + result/error, no method). + _ => { + let rid = frame.get("id").and_then(Value::as_u64); + let Some(rid) = rid else { continue }; + + // initialize response → parse advertised authMethods[] into + // Discovered (capabilities() merges → auth_methods + answer_auth). + let is_init = *pending_init.lock().await == Some(rid); + if is_init { + *pending_init.lock().await = None; + if let Some(result) = frame.get("result") { + handle_initialize_response(result, &discovered); + } else if frame.get("error").is_some() { + // 9a-ACP: a connect-time `initialize` ERROR (the agent rejects + // the handshake — typically NOT logged in / unsupported protocol) + // was previously swallowed (no else arm) → the first prompt hung + // in bound_session() then failed opaquely. Synthesize an error + // terminal so the reducer (still Starting) routes it to + // Error{Backend{message}}, carrying the cause to the 9c + // classifier (→ CheckAgentLogin). Peek stderr so a generic + // JSON-RPC message gets the allowlisted auth cause enriched in. + emit_connect_error(&frame, &io, &event_tx, &session_id, &turn_gen).await; + // Clear the open marker too: session/new|load will never come + // after a failed initialize, so unblock any waiter (bound_session + // / reconcile) instead of letting it spin out its window. + *pending_open.lock().await = None; + } + continue; + } + + // session/new|load response → bind ACP sid + discovery + BackendBound. + let is_open = *pending_open.lock().await == Some(rid); + if is_open { + if let Some(result) = frame.get("result") { + handle_open_response( + result, + &session_id, + &turn_gen, + &event_tx, + &acp_session_id, + &pending_resume_sid, + &discovered, + ) + .await; + } else if frame.get("error").is_some() { + // 9a-ACP: a connect-time `session/new`|`session/load` ERROR + // (auth required / bad resume sid / setup rejected) was + // previously swallowed → opaque hang. Synthesize an error + // terminal (reducer Starting → Error{Backend{message}}), with + // the stderr cause enriched in. + emit_connect_error(&frame, &io, &event_tx, &session_id, &turn_gen).await; + } + // G6: clear `pending_open` ONLY AFTER discovery is filled (or the + // error is emitted), so the startup-reconcile task (which waits on + // `pending_open` clearing) observes the parsed `current_mode/model`, + // never a half-filled race. + *pending_open.lock().await = None; + continue; + } + + // session/set_mode | session/set_model response. + // + // A JSON-RPC ERROR (e.g. opencode `-32602 model not found`) is + // surfaced as a Notice{Warning} + error log so a FAILED set is + // visible instead of being silently reported as success. + // + // A SUCCESS response (any non-error, including opencode's empty + // `{}`) is itself the authoritative "applied" signal: emit + // ConfigChanged with the just-set value and authoritatively + // advance the discovered current_mode/model. We must NOT wait for + // an echo notification — claude-acp echoes `config_option_update` + // (LIVE-VERIFIED), but opencode 1.16.2 sends `{}` + ZERO + // notifications (LIVE round-trip probe), so an echo-only path + // leaves the selector stuck on the open-time value forever + // ("set doesn't stick"). The label is `"mode→"` / `"model→"`. + // If an agent later DOES echo, its ConfigChanged carries the same + // value (reducer is idempotent) — the two paths never conflict. + if let Some(label) = pending_set.lock().await.remove(&rid) { + if let Some(err) = frame.get("error") { + let message = err + .get("message") + .and_then(Value::as_str) + .unwrap_or("set rejected") + .to_string(); + tracing::error!( + conversation_id = %session_id, + set = %label, + "ACP set_mode/set_model rejected by agent: {message}" + ); + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::Notice { + // NoticeLevel has no Error tier; Warning is the + // highest user-facing level (the error-ness is in + // the message + the error! log above). + level: crate::event::NoticeLevel::Warning, + message: format!("{label} failed: {message}"), + }, + ); + } else if let Some((kind, value)) = label.split_once('\u{2192}') { + // Success: authoritatively converge on the set value. + let value = value.to_string(); + { + let mut disc = discovered.lock().unwrap_or_else(|e| e.into_inner()); + match kind { + "mode" => disc.current_mode = Some(value.clone()), + "model" => disc.current_model = Some(value.clone()), + _ => {} + } + } + let config_changed = match kind { + "mode" => Some(SessionEvent::ConfigChanged { + mode: Some(value), + model: None, + }), + "model" => Some(SessionEvent::ConfigChanged { + mode: None, + model: Some(value), + }), + _ => None, + }; + if let Some(ev) = config_changed { + emit(&event_tx, &session_id, turn_gen.load(Ordering::SeqCst), ev); + } + } + continue; + } + + // session/prompt response → THE ACP TERMINAL. Synthesize + // TurnResult from stopReason (success/error/cancelled). + let pending = pending_prompts.lock().await.remove(&rid); + if let Some(pending) = pending { + // F-4: the prompt-response terminal ends the turn → clear + // the turn-active flag so the idle timer may suspend. + turn_in_flight.store(false, Ordering::SeqCst); + // G1-B: only peek stderr for an ERROR terminal (the cause + // of a generic JSON-RPC error lives there); a success + // response never needs it — avoids a peek every turn. + let stderr_tail = if frame.get("error").is_some() { + Some(io.peek_stderr(crate::adapter::STDERR_PEEK_LINES).await) + } else { + None + }; + // Terminal usage: the session/prompt RESPONSE carries + // result.usage{inputTokens,outputTokens,totalTokens, cost?} + // on bridges that report it (claude-agent-acp, hermes). It + // was previously DROPPED (only the streaming usage_update, + // which lacks the per-direction split, was read). Emit a + // UsageDelta BEFORE the terminal (mirrors claude C-2). + if let Some(usage_ev) = parse_acp_result_usage(&frame) { + emit(&event_tx, &session_id, pending.turn_gen, usage_ev); + } + let ev = synth_turn_result(&frame, pending.turn_gen, stderr_tail.as_deref()); + emit(&event_tx, &session_id, pending.turn_gen, ev); + } + } + } + } + Ok(None) => break, + Err(_) => break, + } + } + + // F-4: the reader loop ended (process exited / stdout EOF) → any in-flight turn + // is terminal. Clear the turn-active flag so the idle timer is unblocked. + turn_in_flight.store(false, Ordering::SeqCst); + + // Backend session gone (process exited / stdout EOF): signal the live binding + // is dead (the threadId/sid stays the resume anchor; conversation persisted it). + let was_bound = acp_session_id.lock().await.is_some(); + if was_bound { + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::BackendBound { + backend_session_id: None, + }, + ); + } + let exit = io.wait_for_exit().await; + // G2: redact the stderr tail at the backend boundary so a crash carries a + // user-facing reason (allowlisted, ≤240 chars) without leaking raw stderr. + let redacted_summary = crate::adapter::redact_exit_stderr(io.as_ref()).await; + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::Detached { exit, redacted_summary }, + ); +} + +fn emit(tx: &broadcast::Sender, session_id: &str, turn_gen: u64, event: SessionEvent) { + let _ = tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen, + event, + }); +} + +/// 9a-ACP: synthesize + emit the terminal for a connect-time handshake ERROR +/// (`initialize` / `session/new` / `session/load` returning a JSON-RPC `error` +/// instead of a `result`). Reuses [`synth_turn_result`] (the error-frame arm → +/// `TurnResult{is_error:true, message, api_error_status}`) so the connect error +/// shares the turn-error shape; the reducer, still in `Starting`, routes it to +/// `Error{Backend{message}}` (reducer.rs R16/3.9) — carrying the cause to the 9c +/// classifier (auth → `CheckAgentLogin`) instead of the opaque hang the missing +/// `else` arm caused. Peeks stderr (the agent logs the real cause there without +/// echoing it in the JSON-RPC error) so a generic message is enriched (G1-B/S0). +/// Stamped at the live `turn_gen` (0 at connect): the `Starting` error-TurnResult +/// arm applies no epoch guard, so the cause is never dropped. +async fn emit_connect_error( + frame: &Value, + io: &Arc, + event_tx: &broadcast::Sender, + session_id: &str, + turn_gen: &Arc, +) { + let stderr_tail = io.peek_stderr(crate::adapter::STDERR_PEEK_LINES).await; + let cur = turn_gen.load(Ordering::SeqCst); + let ev = synth_turn_result(frame, cur, Some(stderr_tail.as_str())); + emit(event_tx, session_id, cur, ev); +} + +/// Handle the `initialize` RESPONSE: parse the advertised `authMethods[]` (each +/// `{id, name?, description?}`, ACP `AuthMethod`) into `Discovered.auth_methods`. +/// `capabilities()` merges them → non-empty flips `auth_methods` + the +/// `answer_auth` cap true. hermes advertises `[bedrock, hermes-setup]`; claude +/// ACP advertises none → stays empty (honest, unchanged). Sync-only (no await / +/// no emit): auth methods are a capability fact, not a stream event. +fn handle_initialize_response(result: &Value, discovered: &Arc>) { + let methods: Vec = result + .get("authMethods") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|m| m.get("id").and_then(Value::as_str).map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + if !methods.is_empty() { + let mut disc = discovered.lock().unwrap_or_else(|e| e.into_inner()); + disc.auth_methods = methods; + } +} + +/// Handle the `session/new` | `session/load` RESPONSE: bind the ACP session id +/// (→ BackendBound resume anchor) and fill discovered models/modes into the +/// capability snapshot (ACP carries them in this one response, unlike codex). +async fn handle_open_response( + result: &Value, + session_id: &str, + turn_gen: &Arc, + event_tx: &broadcast::Sender, + acp_session_id: &Arc>>, + pending_resume_sid: &Arc>>, + discovered: &Arc>, +) { + let cur = turn_gen.load(Ordering::SeqCst); + // Bind the ACP session id. `session/new` carries the freshly-minted `sessionId` + // in its result; `session/load` does NOT (it returns config/null) — its sid is + // the one we asked to re-attach, stashed in `pending_resume_sid`. Binding here + // (on the RESPONSE, not pre-seeded) is what makes `bound_session()` block the + // first prompt until the load actually completed (ACP spec ordering). + // The stashed resume sid is consumed (one-shot per handshake), then used as the + // fallback when the response carries no `sessionId` (the session/load case). + let resume_sid = pending_resume_sid.lock().await.take(); + let bound_sid = result + .get("sessionId") + .and_then(Value::as_str) + .map(str::to_string) + .or(resume_sid); + if let Some(sid) = bound_sid { + *acp_session_id.lock().await = Some(sid.clone()); + emit( + event_tx, + session_id, + cur, + SessionEvent::BackendBound { + backend_session_id: Some(sid), + }, + ); + } + // models / modes. TWO wire shapes across ACP agents (both LIVE-pinned): + // - claude-acp 0.33.x: TOP-LEVEL `result.models`/`result.modes` + // (SessionModelState{availableModels[],currentModelId} / SessionModeState). + // - opencode 1.16.2: inside `result.configOptions[]` as items `{id:"model"|"mode", + // currentValue, options:[{value,name,description}]}` (value = provider-prefixed + // full id, e.g. `amazon-bedrock/openai.gpt-5.5`). NO top-level models/modes. + // We parse BOTH (top-level first, then configOptions fallback) so model/mode are + // never dropped → config-options non-empty → the picker has real values and a + // set_model does not -32602 on a stale hardcoded id (opencode prod bug). See + // protocols/design/aioncore-opencode-acp-configoptions-model-mode-prompt.md. + let mut disc = discovered.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(models) = result.get("models") { + let avail = parse_models(models); + if !avail.is_empty() { + disc.models = avail; + } + if let Some(cur_id) = models.get("currentModelId").and_then(Value::as_str) { + disc.current_model = Some(cur_id.to_string()); + } + } + if let Some(modes) = result.get("modes") { + let avail = parse_modes(modes); + if !avail.is_empty() { + disc.modes = avail; + } + if let Some(cur_id) = modes.get("currentModeId").and_then(Value::as_str) { + disc.current_mode = Some(cur_id.to_string()); + } + } + // opencode fallback: extract model/mode from configOptions[] when the top-level + // keys were absent/empty. `options[]{value,name,description}` → ModelInfo/ModeInfo + // (value→id); `currentValue` → current_model/current_mode. + let config_options = result.get("configOptions"); + if disc.models.is_empty() + && let Some((opts, current)) = config_option_select(config_options, "model") + { + disc.models = opts + .into_iter() + .map(|(value, name, description)| ModelInfo { + id: value, + name, + description, + reasoning_efforts: Vec::new(), + }) + .collect(); + if disc.current_model.is_none() { + disc.current_model = current; + } + } + if disc.modes.is_empty() + && let Some((opts, current)) = config_option_select(config_options, "mode") + { + disc.modes = opts + .into_iter() + .map(|(value, name, description)| ModeInfo { + id: value, + name, + description, + }) + .collect(); + if disc.current_mode.is_none() { + disc.current_mode = current; + } + } + // G4: generic config option ids (NOT mode/model — those have dedicated arms), + // e.g. claude-acp `effort`. Gates Command::SetConfigOption (advertised ⟺ settable). + let generic = parse_generic_config_option_ids(config_options); + if !generic.is_empty() { + disc.config_options = generic; + } +} + +/// opencode shape: pull the `configOptions[]` item with `id == want` (e.g. "model" / +/// "mode") and return its `(options:[(value,name,description)], currentValue)`. None +/// if absent. The `value` token is what `dispatch(SetModel/SetMode)` sends back +/// (opencode validates against it), so it becomes the `id`. (claude-acp carries +/// model/mode top-level instead, handled before this is reached.) +#[allow(clippy::type_complexity)] +fn config_option_select( + config_options: Option<&Value>, + want: &str, +) -> Option<(Vec<(String, String, Option)>, Option)> { + let arr = config_options.and_then(Value::as_array)?; + let item = arr.iter().find(|o| o.get("id").and_then(Value::as_str) == Some(want))?; + let options = item + .get("options") + .and_then(Value::as_array) + .map(|opts| { + opts.iter() + .filter_map(|o| { + let value = o.get("value").and_then(Value::as_str)?.to_string(); + let name = o.get("name").and_then(Value::as_str).unwrap_or(&value).to_string(); + let description = o.get("description").and_then(Value::as_str).map(str::to_string); + Some((value, name, description)) + }) + .collect() + }) + .unwrap_or_default(); + let current = item.get("currentValue").and_then(Value::as_str).map(str::to_string); + Some((options, current)) +} + +/// G4: from a `configOptions[]` array (session/new|load response OR a +/// `config_option_update` notification), the ids that are NOT `mode`/`model` — the +/// generic options the dedicated set_mode/set_model arms do not cover. Wire-pinned +/// to the claude-acp 0.33.1 shape (`[{id, category, type, currentValue, options}]`). +fn parse_generic_config_option_ids(config_options: Option<&Value>) -> Vec { + let Some(arr) = config_options.and_then(Value::as_array) else { + return Vec::new(); + }; + arr.iter() + .filter_map(|o| o.get("id").and_then(Value::as_str)) + .filter(|id| *id != "mode" && *id != "model") + .map(str::to_owned) + .collect() +} + +/// ACP `SessionModelState.availableModels[]` → `ModelInfo`. Each +/// `{modelId, name}` (camelCase ACP wire). +fn parse_models(models: &Value) -> Vec { + models + .get("availableModels") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|m| { + let id = m.get("modelId").and_then(Value::as_str)?; + Some(ModelInfo { + id: id.to_string(), + name: m.get("name").and_then(Value::as_str).unwrap_or(id).to_string(), + description: m.get("description").and_then(Value::as_str).map(str::to_string), + reasoning_efforts: Vec::new(), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// ACP `SessionModeState.availableModes[]` → `ModeInfo`. Each `{id, name}`. +fn parse_modes(modes: &Value) -> Vec { + modes + .get("availableModes") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|m| { + let id = m.get("id").and_then(Value::as_str)?; + Some(ModeInfo { + id: id.to_string(), + name: m.get("name").and_then(Value::as_str).unwrap_or(id).to_string(), + description: m.get("description").and_then(Value::as_str).map(str::to_string), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Server-initiated request (reverse-RPC). `session/request_permission` → surface +/// as `Permission{Tool}` (answered by `dispatch(AnswerPermission)` writing the +/// keyed response). Any other reverse method → immediate `-32601` so the channel +/// never deadlocks (A2/A3 — matches the SDK's own auto-reject + the ACP-audit +/// finding that unhandled reverse RPC must be clean-rejected, not dropped). +async fn handle_reverse_rpc( + method: &str, + frame: &Value, + session_id: &str, + turn_gen: &Arc, + event_tx: &broadcast::Sender, + stdin: &Arc>>, + pending_perm_options: &PendingPermOptions, +) { + let cur = turn_gen.load(Ordering::SeqCst); + let id = frame.get("id").cloned().unwrap_or(Value::Null); + match method { + "session/request_permission" => { + // The wire `id` is the request_id the conversation answers. The backend + // does NOT decide — it parses G3 context (MCP server name + answerable + // options) into `metadata` so the conversation facade can consult its + // injected PermissionAuthorizer (team-MCP allowlist auto-approval). A + // human still decides for everything the authorizer does not auto-approve. + let metadata = parse_permission_metadata(frame.get("params")); + // Remember the OFFERED options (real optionId + kind) keyed by request_id, + // so dispatch(AnswerPermission) can echo a REAL optionId picked by kind + // (Approved→allow_once, AllowAlways→allow_always, Denied→reject_once) — the + // agent rejects a fabricated id. Stored before the emit so the answer (which + // may race back fast) always finds it. + if let Some(opts) = frame + .get("params") + .and_then(|p| p.get("options")) + .and_then(Value::as_array) + { + let parsed: Vec<(String, String)> = opts + .iter() + .filter_map(|o| { + let oid = o.get("optionId").and_then(Value::as_str)?.to_string(); + let kind = o.get("kind").and_then(Value::as_str).unwrap_or("").to_string(); + Some((oid, kind)) + }) + .collect(); + if !parsed.is_empty() { + pending_perm_options.lock().await.insert(id.to_string(), parsed); + } + } + emit( + event_tx, + session_id, + cur, + SessionEvent::Permission { + request_id: id.to_string(), + kind: PermissionKind::Tool, + metadata, + // AskUserQuestion projection is claude-direct only; ACP permission + // requests carry MCP context via `metadata`, not a question payload. + tool_name: None, + input: None, + }, + ); + } + _ => { + write_reverse_error(stdin, &id, -32601, "method not handled by aionui-session").await; + emit( + event_tx, + session_id, + cur, + SessionEvent::AdapterSpecific { + tag: "acp_reverse_rpc".into(), + payload: json!({ "method": method, "id": id }), + }, + ); + } + } +} + +/// G3: parse the `session/request_permission` params into the NON-authoritative +/// `Permission.metadata` the conversation uses for auto-approval. Extracts the MCP +/// server name (codex-style `toolCall.rawInput.server_name`, else claude-style +/// `mcp____` title prefix — ported from the legacy permission_router +/// `extract_mcp_server_name`) and the answerable `options` (optionId + kind), so +/// the conversation can both decide (server allowlist) and pick an allow option +/// without re-reading the wire. Returns `None` when nothing useful is present. +fn parse_permission_metadata(params: Option<&Value>) -> Option { + let params = params?; + let tool_call = params.get("toolCall"); + let server_name = extract_mcp_server_name(tool_call); + let options: Vec = params + .get("options") + .and_then(Value::as_array) + .map(|opts| { + opts.iter() + .filter_map(|o| { + let option_id = o.get("optionId").and_then(Value::as_str)?; + Some(json!({ + "option_id": option_id, + "kind": o.get("kind").and_then(Value::as_str).unwrap_or(""), + // CT-PERM-OPTIONS: carry the human label so the conversation + // can render a clickable option (not just an opaque id). + "name": o.get("name").and_then(Value::as_str).unwrap_or(""), + })) + }) + .collect() + }) + .unwrap_or_default(); + if server_name.is_none() && options.is_empty() { + return None; + } + let mut meta = serde_json::Map::new(); + if let Some(name) = server_name { + meta.insert("server_name".into(), Value::String(name)); + } + if !options.is_empty() { + meta.insert("options".into(), Value::Array(options)); + } + Some(Value::Object(meta)) +} + +/// MCP server name from a `toolCall`: prefer `rawInput.server_name` (codex shape), +/// else the `mcp____` title prefix (claude shape). Mirrors the legacy +/// `extract_mcp_server_name` two-source order. +fn extract_mcp_server_name(tool_call: Option<&Value>) -> Option { + let tool_call = tool_call?; + let from_raw = tool_call + .get("rawInput") + .and_then(|ri| ri.get("server_name")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_owned); + if from_raw.is_some() { + return from_raw; + } + let title = tool_call.get("title").and_then(Value::as_str)?; + let rest = title.strip_prefix("mcp__")?; + let (server, tool) = rest.split_once("__")?; + if server.is_empty() || tool.is_empty() { + return None; + } + Some(server.to_owned()) +} + +/// Write a JSON-RPC ERROR response to unblock a reverse-RPC (A2/A3). Best-effort. +async fn write_reverse_error( + stdin: &Arc>>, + id: &Value, + code: i64, + message: &str, +) { + if id.is_null() { + return; + } + let frame = json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }); + let _ = write_frame_to(stdin, frame).await; +} + +/// Map an ACP `session/update` notification `params` → canonical SessionEvent(s). +/// A1 anti-panic: match on the `sessionUpdate` STRING, never deserialize a closed +/// enum. Unknown `sessionUpdate` → `AdapterSpecific`. `update` shapes verified +/// against real hermes/codex-acp wire (camelCase fields). +async fn map_update( + params: &Value, + current_model: &Arc>>, + discovered: &Arc>, +) -> Vec { + let update = params.get("update").unwrap_or(&Value::Null); + let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or(""); + match kind { + "agent_message_chunk" => { + let text = update + .get("content") + .and_then(|c| c.get("text")) + .and_then(Value::as_str) + .unwrap_or(""); + vec![SessionEvent::MessageDelta { + item_id: "acp:text".into(), + text: text.to_string(), + }] + } + "agent_thought_chunk" => { + let text = update + .get("content") + .and_then(|c| c.get("text")) + .and_then(Value::as_str) + .unwrap_or(""); + vec![SessionEvent::ThoughtDelta { + item_id: "acp:think".into(), + text: text.to_string(), + }] + } + "tool_call" => { + let id = update.get("toolCallId").and_then(Value::as_str).unwrap_or(""); + let title = update.get("title").and_then(Value::as_str).unwrap_or("tool"); + vec![SessionEvent::ToolCall { + tool_use_id: id.to_string(), + name: title.to_string(), + subagent: SubagentKind::Inline, + // Gap #4 / H2: carry the ACP tool ARGUMENTS (`rawInput`, the spec's + // optional pre-parsed tool input). Absent → Value::Null. TIO-13: never + // logged at info. + input: update.get("rawInput").cloned().unwrap_or(Value::Null), + // 009 H5: ACP has no per-frame subagent attribution wire — main agent. + parent_tool_use_id: None, + }] + } + "tool_call_update" => { + // A COMPLETED/FAILED tool is substantive output (ToolResult). Other + // statuses (pending/in_progress) carry no FSM meaning. + let status = update.get("status").and_then(Value::as_str).unwrap_or(""); + if matches!(status, "completed" | "failed") { + let id = update.get("toolCallId").and_then(Value::as_str).unwrap_or(""); + vec![SessionEvent::ToolResult { + tool_use_id: id.to_string(), + // 009 R7/H3: ACP status:failed → is_error (was dropped → a failed + // tool rendered as success, §12.9 TIO-8/9). + is_error: status == "failed", + // 009 R8: carry the tool OUTPUT from the ACP `content[]` (text / + // image / diff) + `locations[]` (touched file paths). A content + // item's inner ContentBlock may be an image (image-file Read / + // screenshot / vision tool) — decoded to ToolResultContent::Image, + // not dropped (protocol-audit fix; the prior "ACP never inlines + // image bytes" claim was false). + content: parse_acp_tool_content(update), + // 009 H5: ACP has no per-frame subagent attribution — main agent. + parent_tool_use_id: None, + }] + } else { + Vec::new() + } + } + "usage_update" => { + // Two ACP shapes, both handled (do not assume cost-free / split-free): + // - hermes: {used, size} (cumulative token count, no split, no cost). + // - claude-agent-acp: richer {inputTokens, outputTokens, totalTokens, + // cost:{amount, currency}}. cost was previously DROPPED (hardcoded None) + // + the per-direction split ignored. cost_usd is consumed downstream + // (turn_finalizer). Read whichever fields are present. + let input = update.get("inputTokens").and_then(Value::as_u64).unwrap_or(0); + let output = update.get("outputTokens").and_then(Value::as_u64).unwrap_or(0); + let total = update + .get("totalTokens") + .and_then(Value::as_u64) + .or_else(|| update.get("used").and_then(Value::as_u64)) + .unwrap_or(input + output); + let cost_usd = update.get("cost").and_then(|c| c.get("amount")).and_then(Value::as_f64); + vec![SessionEvent::UsageDelta { + input_tokens: input, + output_tokens: output, + total_tokens: total, + cost_usd, + }] + } + "current_mode_update" => { + // Real ACP SessionUpdate variant (schema 0.12.0 client.rs:103 CurrentModeUpdate{currentModeId}). + let mode = update.get("currentModeId").and_then(Value::as_str).map(str::to_string); + vec![SessionEvent::ConfigChanged { mode, model: None }] + } + // NOTE: there is NO `current_model_update` SessionUpdate in ACP. The official + // schema (agent-client-protocol-schema 0.12.0) defines CurrentModeUpdate but no + // CurrentModelUpdate — the current model lives in SessionModelState.current_model_id + // inside the session/new|load|set RESULT (read at open + on set_model), not in a + // streaming notification. A prior arm parsed `current_model_update` by symmetry + // with current_mode_update — a guessed frame that does not exist on the wire + // (contracts README #9). REMOVED. Real mid-session model changes ride + // `config_option_update` (LIVE-VERIFIED for claude-acp; handled below) → ConfigChanged. + // If some non-standard agent ever emits a literal `current_model_update`, it falls + // through to the `_ =>` AdapterSpecific catch-all (lossless, no invented parse). + "available_commands_update" => { + // #101: fill the discovered slash-command catalog from + // `update.availableCommands[{name, description, input?}]` (wire-pinned: + // hermes + claude-acp). FSM-orthogonal (no SessionEvent) — still returns + // AdapterSpecific below so the event surface is unchanged. Anti-panic: + // filter_map over the array, never deserialize a closed enum (A1 doctrine). + if let Some(cmds) = update.get("availableCommands").and_then(Value::as_array) { + let parsed: Vec = cmds + .iter() + .filter_map(|c| { + let name = c.get("name").and_then(Value::as_str)?.to_string(); + Some(crate::capability::SlashCommandInfo { + name, + description: c.get("description").and_then(Value::as_str).map(str::to_string), + }) + }) + .collect(); + discovered.lock().unwrap_or_else(|e| e.into_inner()).slash_commands = parsed; + } + vec![SessionEvent::AdapterSpecific { + tag: format!("acp_update:{kind}"), + payload: update.clone(), + }] + } + "config_option_update" => { + // Refresh the discovered GENERIC config-option ids (so the SetConfigOption + // gate stays accurate), AND surface a mode/model change as ConfigChanged. + // + // LIVE-VERIFIED (claude-agent-acp, acp_claude_bridge_set_mode_config_change_behavior): + // some ACP agents (claude-acp) route mode/model changes through + // config_option_update — `configOptions:[{id:"mode",currentValue:"plan"}, + // {id:"model",currentValue:"default"}, ...]` — NOT through + // current_mode_update/current_model_update. Earlier this arm dropped the + // mode/model currentValue (only tracked generic ids), so a claude-acp mode + // switch never reached ConfigChanged → the frontend selector would not + // update (README discipline #10: sense the change however the agent reports + // it). Extract the mode/model currentValue here so BOTH report shapes are + // covered. (acp is not prod-wired yet — backend_router fail-loud — so this + // had no production impact; it is fixed now that the real wire is captured.) + let opts = update.get("configOptions"); + let generic = parse_generic_config_option_ids(opts); + if !generic.is_empty() { + discovered.lock().unwrap_or_else(|e| e.into_inner()).config_options = generic; + } + let current_value = |id: &str| -> Option { + opts.and_then(Value::as_array)? + .iter() + .find(|o| o.get("id").and_then(Value::as_str) == Some(id)) + .and_then(|o| o.get("currentValue").and_then(Value::as_str)) + .map(str::to_string) + }; + let mode = current_value("mode"); + let model = current_value("model"); + let mut events = Vec::new(); + if mode.is_some() || model.is_some() { + if let Some(m) = &model { + *current_model.lock().await = Some(m.clone()); + } + events.push(SessionEvent::ConfigChanged { mode, model }); + } + // Keep the opaque AdapterSpecific too (carries the full options catalog for + // any generic-option consumer; the ConfigChanged above is additive). + events.push(SessionEvent::AdapterSpecific { + tag: format!("acp_update:{kind}"), + payload: update.clone(), + }); + events + } + "plan" => { + // LC-8a: ACP to-do plan snapshot. `update.entries[{content, status, + // priority?}]` (wire-pinned acp-zed/copilot) → SessionEvent::Plan. ACP is + // the superset shape; snake_case `in_progress`→InProgress, priority maps. + // FSM-orthogonal (the reducer no-ops it); a full-replace snapshot. + let entries: Vec = update + .get("entries") + .and_then(Value::as_array) + .map(|es| { + es.iter() + .filter_map(|e| { + let content = e.get("content").and_then(Value::as_str)?.to_string(); + let status = map_plan_status(e.get("status").and_then(Value::as_str).unwrap_or("")); + let priority = map_plan_priority(e.get("priority").and_then(Value::as_str)); + Some(crate::event::PlanEntry { + content, + status, + priority, + }) + }) + .collect() + }) + .unwrap_or_default(); + // ACP plan carries no explanation (codex-only field). + vec![SessionEvent::Plan { + entries, + explanation: None, + }] + } + // session_info_update / user chunk / unknown → FSM-orthogonal opaque + // (never panic on an unknown variant). + _ => vec![SessionEvent::AdapterSpecific { + tag: format!("acp_update:{kind}"), + payload: update.clone(), + }], + } +} + +/// LC-8a: normalize an ACP/codex plan-step status string → canonical `PlanStatus` +/// (I8). camelCase `inProgress` (codex) AND snake_case `in_progress` (ACP) both map +/// to `InProgress`; unknown → `Pending` (never panic). +fn map_plan_status(s: &str) -> crate::event::PlanStatus { + use crate::event::PlanStatus; + match s { + "inProgress" | "in_progress" => PlanStatus::InProgress, + "completed" => PlanStatus::Completed, + _ => PlanStatus::Pending, + } +} + +/// LC-8a: map an ACP plan-step priority string → `PlanPriority` (None when absent / +/// unknown — codex never sets one). +fn map_plan_priority(s: Option<&str>) -> Option { + use crate::event::PlanPriority; + match s { + Some("high") => Some(PlanPriority::High), + Some("medium") => Some(PlanPriority::Medium), + Some("low") => Some(PlanPriority::Low), + _ => None, + } +} + +/// Synthesize the terminal `TurnResult` from a `session/prompt` RESPONSE. This is +/// the ACP-specific terminal: the response's `stopReason` (success/limit/refusal/ +/// cancelled) IS the turn outcome — there is no `turn/completed` notification. +/// `epoch: 0` would let the orchestrator restamp, but we already know the turn +/// epoch (the prompt's `pending_prompts` entry), so we stamp it directly. +/// G1-B/C: synthesize the ACP terminal TurnResult from the prompt-response frame. +/// `stderr_tail` (G1-B) is the backend's recent stderr — passed in by the reader +/// (which owns `io`) so this stays a pure, testable function. When the JSON-RPC +/// error message is generic, the allowlisted stderr cause (S0) is appended; the +/// classifier (G1-C) maps a known-generic message to a friendlier user tip. +/// Parse a `session/prompt` RESPONSE's `result.usage` into a terminal UsageDelta +/// (claude-agent-acp / hermes carry it: {inputTokens, outputTokens, totalTokens, +/// cost:{amount}}). Previously DROPPED — only the streaming usage_update was read, +/// which lacks the per-direction split. None when no usage present or all-zero. +fn parse_acp_result_usage(frame: &Value) -> Option { + let usage = frame.get("result")?.get("usage")?; + let input = usage.get("inputTokens").and_then(Value::as_u64).unwrap_or(0); + let output = usage.get("outputTokens").and_then(Value::as_u64).unwrap_or(0); + let total = usage + .get("totalTokens") + .and_then(Value::as_u64) + .unwrap_or(input + output); + let cost_usd = usage.get("cost").and_then(|c| c.get("amount")).and_then(Value::as_f64); + if total == 0 && cost_usd.is_none() { + return None; + } + Some(SessionEvent::UsageDelta { + input_tokens: input, + output_tokens: output, + total_tokens: total, + cost_usd, + }) +} + +fn synth_turn_result(frame: &Value, turn_gen: u64, stderr_tail: Option<&str>) -> SessionEvent { + // A JSON-RPC error response (the prompt itself failed) → error terminal. + if let Some(err) = frame.get("error") { + let raw = err + .get("message") + .and_then(Value::as_str) + .unwrap_or("ACP prompt failed"); + let message = enrich_acp_error_message(raw, stderr_tail); + let api_error_status = err + .get("data") + .and_then(|d| d.get("httpStatusCode")) + .and_then(Value::as_u64) + .map(|n| n as u16); + return SessionEvent::TurnResult { + is_error: true, + api_error_status, + result_text: message, + epoch: turn_gen, + outcome: TurnOutcome::Failed, + }; + } + // A normal response carries `result.stopReason`. + let stop = frame + .get("result") + .and_then(|r| r.get("stopReason")) + .and_then(Value::as_str) + .unwrap_or("end_turn"); + let outcome = map_stop_reason(stop); + // ACP has no result text; the empty-turn signal is "no substantive output" + // (the reducer decides from folded deltas) AND empty result_text. A non-error + // stop reason carries an empty result_text → reducer's saw-substantive gate + // (deltas already folded by this single-reader ordering) decides Idle vs Empty. + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: String::new(), + epoch: turn_gen, + outcome, + } +} + +/// G1-B/C: turn a raw ACP JSON-RPC error message into a user-facing one. When the +/// message is generic (`internal` / `unknown` / `failed` / empty-ish), the real +/// cause is almost always on the agent's stderr (codex-acp/claude-acp log it as a +/// tracing event WITHOUT echoing it in the JSON-RPC error). If the allowlisted S0 +/// extractor finds a cause there, append it: `" ()"`. A +/// non-generic message (already specific) is returned unchanged. Pure function: +/// the reader peeks stderr and passes it in. +fn enrich_acp_error_message(raw: &str, stderr_tail: Option<&str>) -> String { + if !is_generic_error_message(raw) { + return raw.to_string(); + } + if let Some(tail) = stderr_tail + && let Some(cause) = aionui_common::error_extract::extract_error_message(tail) + { + return format!("{raw} ({cause})"); + } + raw.to_string() +} + +/// A JSON-RPC error message is "generic" (worth enriching from stderr) when it +/// carries no actionable detail — the ACP bridges emit these for upstream failures +/// whose real cause is only on stderr. Matches the legacy `acp_error_public_message` +/// `AgentInternal` intent without needing the typed SDK error. +fn is_generic_error_message(msg: &str) -> bool { + let m = msg.trim().to_lowercase(); + m.is_empty() + || m == "acp prompt failed" + || m.contains("internal error") + || m.contains("internal server error") + || m == "internal" + || m == "unknown" + || m == "failed" + || m == "unknown error" +} + +/// G6 (functional core): decide which SetMode/SetModel commands re-align a freshly +/// opened/resumed ACP session's OBSERVED mode/model (what the agent came up with, +/// from the session/new|load response) to the DESIRED values (the conversation's +/// config). Pure + total — the thin shell (`reconcile_startup_config`) dispatches +/// the returned commands. +/// +/// A dimension is reconciled ONLY when the agent ADVERTISED it (`*_supported`) — so +/// a mode-less agent never gets a spurious set_mode — AND a desired value exists AND +/// it differs from observed (including observed=None: the agent advertised the +/// dimension but did not report a current, so we still align to desired). Mirrors +/// the legacy `agent_reconcile::plan_reconcile` intent without its typed plan struct. +fn reconcile_plan( + modes_supported: bool, + desired_mode: Option<&str>, + observed_mode: Option<&str>, + models_supported: bool, + desired_model: Option<&str>, + observed_model: Option<&str>, +) -> Vec { + let mut out = Vec::new(); + if modes_supported + && let Some(want) = desired_mode + && observed_mode != Some(want) + { + out.push(Command::SetMode { mode: want.to_string() }); + } + if models_supported + && let Some(want) = desired_model + && observed_model != Some(want) + { + out.push(Command::SetModel { + model: want.to_string(), + }); + } + out +} + +/// Map an ACP `stopReason` (snake_case wire) → `TurnOutcome`. +fn map_stop_reason(stop: &str) -> TurnOutcome { + match stop { + "end_turn" => TurnOutcome::Completed { + stop_reason: StopReason::EndTurn, + }, + "max_tokens" => TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::MaxTokens), + }, + "max_turn_requests" => TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::MaxTurns), + }, + "refusal" => TurnOutcome::Completed { + stop_reason: StopReason::Refused { category: None }, + }, + "cancelled" => TurnOutcome::Cancelled { + reason: crate::event::CancelReason::UserCancel, + }, + // Unknown future stopReason → treat as a clean end (never panic). + _ => TurnOutcome::Completed { + stop_reason: StopReason::EndTurn, + }, + } +} + +/// Map our multimodal `ContentBlock`s → ACP `session/prompt.prompt` content +/// blocks (camelCase wire). Text + image (advertised); others dropped (UI gated +/// by prompt_blocks). +fn build_prompt_blocks(content: &[ContentBlock]) -> Vec { + content + .iter() + .filter_map(|b| match b { + ContentBlock::Text(t) => Some(json!({ "type": "text", "text": t })), + ContentBlock::Image { data, media_type } => { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(data); + Some(json!({ "type": "image", "mimeType": media_type, "data": b64 })) + } + ContentBlock::ResourceLink { uri, mime_type } => Some(json!({ + "type": "resource_link", "uri": uri, "mimeType": mime_type + })), + _ => None, + }) + .collect() +} + +/// 009 R8: extract a completed ACP `tool_call_update`'s OUTPUT into the +/// backend-neutral `ToolResultContent` Vec. ACP carries it two ways: +/// - `content[]` items — `{type:"content", content:{type:"text", text}}` → `Text`; +/// `{type:"diff", path, oldText, newText}` → `FilePath` with the diff text. +/// - `locations[]` — `{path}` files the tool touched → `FilePath` references. +/// +/// A `content` item's inner ContentBlock may be an image (image-file Read / +/// screenshot / vision tool) → decoded to `Image` (base64 `data` + `mimeType`); +/// audio / resource_link / unknown have no neutral mapping yet and are dropped. +/// Wire is camelCase; we also accept snake_case defensively. +fn parse_acp_tool_content(update: &Value) -> Vec { + use crate::event::ToolResultContent; + let mut out = Vec::new(); + if let Some(items) = update.get("content").and_then(Value::as_array) { + for it in items { + match it.get("type").and_then(Value::as_str) { + Some("content") => { + // A ToolCallContent{type:content, content:}. The + // inner ContentBlock is text OR image (ACP ContentBlock variants) — + // previously only text was read, so a tool returning an image + // (image-file Read, screenshot, vision tool) silently dropped its + // bytes. Carry the image block too (same {data:base64, mimeType} + // shape we send in build_prompt_blocks). + let inner = it.get("content"); + match inner.and_then(|c| c.get("type")).and_then(Value::as_str) { + Some("text") | None => { + if let Some(text) = inner.and_then(|c| c.get("text")).and_then(Value::as_str) { + out.push(ToolResultContent::Text(text.to_string())); + } + } + Some("image") => { + use base64::Engine as _; + let media_type = inner + .and_then(|c| c.get("mimeType")) + .and_then(Value::as_str) + .unwrap_or("image/png") + .to_string(); + if let Some(bytes) = inner + .and_then(|c| c.get("data")) + .and_then(Value::as_str) + .and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok()) + { + out.push(ToolResultContent::Image { + media_type, + data: bytes, + }); + } + } + _ => {} // audio / resource_link / unknown — no neutral mapping yet + } + } + Some("diff") => { + if let Some(path) = it.get("path").and_then(Value::as_str) { + let pick = |a: &str, b: &str| { + it.get(a) + .or_else(|| it.get(b)) + .and_then(Value::as_str) + .map(str::to_string) + }; + out.push(ToolResultContent::FilePath { + path: path.to_string(), + mime: None, + old_text: pick("oldText", "old_text"), + new_text: pick("newText", "new_text"), + }); + } + } + _ => {} + } + } + } + if let Some(locs) = update.get("locations").and_then(Value::as_array) { + for loc in locs { + if let Some(path) = loc.get("path").and_then(Value::as_str) { + // Avoid duplicating a path already carried by a diff item. + let dup = out + .iter() + .any(|c| matches!(c, ToolResultContent::FilePath { path: p, .. } if p == path)); + if !dup { + out.push(ToolResultContent::FilePath { + path: path.to_string(), + mime: None, + old_text: None, + new_text: None, + }); + } + } + } + } + out +} + +#[async_trait::async_trait] +impl SessionBackend for AcpSessionBackend { + async fn dispatch(&self, command: Command) -> Result { + match command { + Command::Send { content, metadata } => { + // §C6 Layer-2: reject any block kind ACP does not advertise + // (prompt_blocks: text + image + resource) BEFORE wire-write — + // never silently drop it ("adapter authoritatively rejects → CommandNotSupported, + // never a silent drop"). An audio / at_mention block is rejected, keyed on + // its `content_block:` name (parity with codex/claude). + let blocks = self.capabilities().prompt_blocks; + if let Some(bad) = content.iter().find(|b| !blocks.allows(b)) { + return Err(BackendError::CommandNotSupported { + command: crate::capability::block_kind_name(bad), + }); + } + // F-4: ensure the ACP CLI is awake before the wire write. idle_ttl= + // None (default) → slot always Active → one uncontended lock, no + // re-spawn (pre-F-4 parity). When suspended, re-spawn + replay the + // session/load handshake first. + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + // F-4: mark the turn in flight so the idle timer won't suspend the + // ACP CLI mid-turn (the reader clears it at the prompt-response terminal). + self.turn_in_flight.store(true, Ordering::SeqCst); + let sid = self.bound_session().await?; + let cur_gen = self.turn_gen.fetch_add(1, Ordering::SeqCst) + 1; + let id = self.next_rpc_id(); + // Record the pending prompt so the reader synthesizes the terminal + // from its response's stopReason (the ACP terminal path). + self.pending_prompts + .lock() + .await + .insert(id, PendingPrompt { turn_gen: cur_gen }); + // Wave 0c-F: on the FIRST send, prepend the preset `[Assistant Rules]` + // block as a leading text block (ACP has no system-prompt field). + // Drained once via take() so later turns are unaffected. + let mut prompt = build_prompt_blocks(&content); + if let Some(preamble) = self.pending_preamble.lock().await.take() { + prompt.insert(0, json!({ "type": "text", "text": preamble })); + } + self.write_frame(json!({ + "jsonrpc": "2.0", "id": id, "method": "session/prompt", + "params": { "sessionId": sid, "prompt": prompt } + })) + .await?; + // PromptAccepted is Synthesized: the prompt is on the wire, so the + // conversation's pending queue can drain now (the terminal arrives + // later via the response). Only when a client_msg_id was supplied. + if let Some(cmid) = metadata.client_msg_id { + emit( + &self.event_tx, + &self.session_id, + cur_gen, + SessionEvent::PromptAccepted { client_msg_id: cmid }, + ); + } + Ok(CommandReceipt { + accepted: true, + admission: Admission::Started, + turn_gen: cur_gen, + }) + } + Command::Cancel { target } => { + if let CancelTarget::Tool(_) = target { + return Err(BackendError::CommandNotSupported { command: "cancel_tool" }); + } + // ACP `session/cancel` is a NOTIFICATION (no id, no response). The + // agent confirms by returning stopReason:cancelled on the in-flight + // prompt response (→ the reader synthesizes TurnResult{Cancelled}). + let sid = self.bound_session().await?; + self.write_frame(json!({ + "jsonrpc": "2.0", "method": "session/cancel", "params": { "sessionId": sid } + })) + .await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::SetMode { mode } => { + // F-4: between-turn config write → wake a suspended session first. + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + let sid = self.bound_session().await?; + let id = self.next_rpc_id(); + // Register so the reader surfaces a JSON-RPC error response (e.g. an + // invalid modeId) as a Notice{Error} instead of silently dropping it. + self.pending_set.lock().await.insert(id, format!("mode→{mode}")); + self.write_frame(json!({ + "jsonrpc": "2.0", "id": id, "method": "session/set_mode", + "params": { "sessionId": sid, "modeId": mode } + })) + .await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::SetModel { model } => { + // F-4: between-turn config write → wake a suspended session first. + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + let sid = self.bound_session().await?; + *self.current_model.lock().await = Some(model.clone()); + let id = self.next_rpc_id(); + // Register so the reader surfaces a JSON-RPC error response (e.g. + // opencode `-32602 model not found` on a stale/invalid id) as a + // Notice{Error} instead of silently reporting success (the prod bug). + self.pending_set.lock().await.insert(id, format!("model→{model}")); + self.write_frame(json!({ + "jsonrpc": "2.0", "id": id, "method": "session/set_model", + "params": { "sessionId": sid, "modelId": model } + })) + .await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::AnswerPermission { + request_id, + decision, + selected, + answers: _, // ACP outcome is a single optionId; no per-question set + } => { + // Write the reverse-RPC RESPONSE keyed by the request id we surfaced + // as Permission.request_id. ACP `RequestPermissionResponse.outcome` is + // `{outcome:"selected", optionId}` (a REAL optionId the agent offered) + // or `{outcome:"cancelled"}` (a client-side abort, NOT a deny). + // + // FIX (protocol audit): we must echo a REAL optionId from the offered + // set, chosen by KIND — NOT a hardcoded "allow_once"/"cancelled" (every + // bridge rejected the fabricated allow_once → "Always Allow" silently + // denied the tool; "cancelled" routed a DENY to the abort path, not a + // clean reject). The offered options were stashed at request time. + let id: Value = serde_json::from_str(&request_id).unwrap_or(Value::String(request_id.clone())); + let offered = self.pending_perm_options.lock().await.remove(&request_id); + // Want-kind by decision: AllowAlways→allow_always (fallback allow_once), + // Approved→allow_once, Denied→reject_once (fallback reject_always). + let pick_by_kind = |offered: &[(String, String)], wants: &[&str]| -> Option { + wants + .iter() + .find_map(|w| offered.iter().find(|(_, kind)| kind == w).map(|(oid, _)| oid.clone())) + }; + let outcome = if let Some(sel) = selected { + // An explicit user pick (pick-one card) — the conversation already + // resolved it to a real offered optionId. + json!({ "outcome": "selected", "optionId": sel }) + } else if let Some(offered) = offered.as_deref() { + let chosen = match decision { + PermissionDecision::AllowAlways => pick_by_kind(offered, &["allow_always", "allow_once"]), + PermissionDecision::Approved => pick_by_kind(offered, &["allow_once", "allow_always"]), + PermissionDecision::Denied => pick_by_kind(offered, &["reject_once", "reject_always"]), + }; + match chosen { + Some(oid) => json!({ "outcome": "selected", "optionId": oid }), + // No matching option offered → cancelled is the only honest fallback. + None => json!({ "outcome": "cancelled" }), + } + } else { + // No options were captured (defensive — shouldn't happen for a real + // request_permission). Approve→cancelled would be wrong; only deny/ + // unknown falls back to cancelled. + json!({ "outcome": "cancelled" }) + }; + self.write_frame(json!({ + "jsonrpc": "2.0", "id": id, "result": { "outcome": outcome } + })) + .await?; + // 009 RA -1 (codex·ACP symmetry): the reducer leaves requires-action + // ONLY on PermissionResolved. ACP previously wrote the wire response + // but never broadcast PermissionResolved, so `waiting_on_approval` + // stayed +1 and can_send was stuck false until the whole turn folded + // to Idle (and with multiple pending permissions the count could never + // reach 0). Mirror the claude/codex peers. The ACP reverse-RPC response + // IS the resolve (no separate out-of-band resolved notification), so + // emit exactly once here, keyed Tool (session/request_permission is the + // only Permission source; Auth goes through connection-level authenticate). + let cur_gen = self.turn_gen.load(Ordering::SeqCst); + emit( + &self.event_tx, + &self.session_id, + cur_gen, + SessionEvent::PermissionResolved { + request_id: request_id.clone(), + kind: PermissionKind::Tool, + }, + ); + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: cur_gen, + }) + } + Command::Acknowledge { .. } => { + // Conversation-side fold (done-unseen → seen). No ACP wire. Accept. + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + // ACP base wire has no equivalent → reject (cap advertises false). + Command::Steer { .. } => Err(BackendError::CommandNotSupported { command: "steer" }), + // G4: ACP exposes generic config options (e.g. claude-acp `effort`, + // category `thought_level`) via `session/set_config_option {sessionId, + // optionId, value}` (the SDK method, written as raw JSON-RPC). mode/model + // still go through their dedicated set_mode/set_model arms (the conversation + // routes id=="mode"/"model" there); anything else (effort/custom) lands + // here instead of being rejected. Gated on the agent ADVERTISING the option + // (cap-behavior invariant: an agent that discovered no config options + // rejects, so the surface stays honest). + Command::SetConfigOption { option_id, value } => { + if !self.has_config_option(&option_id) { + return Err(BackendError::CommandNotSupported { + command: "set_config_option", + }); + } + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + let sid = self.bound_session().await?; + let id = self.next_rpc_id(); + // Register the pending set so the reader surfaces a JSON-RPC ERROR + // response (e.g. an invalid option value) as a Notice{Warning} instead + // of silently dropping it — same visibility set_mode/set_model already + // have. The label kind is `config:` (NOT mode/model), so the + // reader's success branch emits no ConfigChanged: generic config options + // have no field on the ConfigChanged event (it is mode/model-only), and + // their convergence rides the agent's own config_option_update echo + + // the discovered-options refresh — only the FAILURE path needed wiring. + self.pending_set + .lock() + .await + .insert(id, format!("config:{option_id}\u{2192}{value}")); + self.write_frame(json!({ + "jsonrpc": "2.0", "id": id, "method": "session/set_config_option", + "params": { "sessionId": sid, "optionId": option_id, "value": value } + })) + .await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::AnswerAuth { method_id, .. } => { + // D2: only when the agent advertised authMethods at initialize + // (answer_auth cap dynamically true) do we honor this. ACP auth is a + // CONNECTION-level `authenticate` request keyed by methodId — NOT a + // reverse-RPC response (unlike AnswerPermission). The credentials are + // supplied out-of-band by the chosen method (hermes bedrock = ambient + // runtime creds; hermes-setup = interactive terminal), so we send only + // the methodId. (K2 — whether an in-flight turn can resume after a + // mid-session re-auth — is unresolved; this connection-level write is + // the open-time / between-turn auth the cap honestly advertises.) + if !self.capabilities().supported_commands.answer_auth { + return Err(BackendError::CommandNotSupported { command: "answer_auth" }); + } + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + let id = self.next_rpc_id(); + self.write_frame(json!({ + "jsonrpc": "2.0", "id": id, "method": "authenticate", + "params": { "methodId": method_id } + })) + .await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::Rewind { .. } => Err(BackendError::CommandNotSupported { command: "rewind" }), + Command::ListCheckpoints => Err(BackendError::CommandNotSupported { + command: "list_checkpoints", + }), + // ACP has no cumulative usage/cost query wire → reject (cap=false). + Command::QuerySessionInfo { .. } => Err(BackendError::CommandNotSupported { + command: "query_session_info", + }), + } + } + + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + let rx = self.event_tx.subscribe(); + futures_util::stream::unfold(rx, |mut rx| async move { + loop { + match rx.recv().await { + Ok(env) => return Some((env, rx)), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + }) + .boxed() + } + + fn capabilities(&self) -> Capabilities { + // Merge the reader-discovered models/modes (from the session/new|load + // response) into the open-time base snapshot. Read-only sync lock — no + // await (parity with codex's capabilities()). + let mut caps = self.capabilities.clone(); + let disc = self.discovered.lock().unwrap_or_else(|e| e.into_inner()); + if !disc.models.is_empty() { + caps.available_models = disc.models.clone(); + } + if !disc.modes.is_empty() { + caps.available_modes = disc.modes.clone(); + } + if disc.current_model.is_some() { + caps.current_model = disc.current_model.clone(); + } + if disc.current_mode.is_some() { + caps.current_mode = disc.current_mode.clone(); + } + // Auth methods advertised in the initialize response (D2). When the agent + // advertises any (hermes), surface them AND flip `answer_auth` true so the + // cap-behavior invariant holds (advertised ⟺ dispatch does not reject). claude + // ACP advertises none → stays empty + answer_auth false (honest, unchanged). + if !disc.auth_methods.is_empty() { + caps.auth_methods = disc.auth_methods.clone(); + caps.supported_commands.answer_auth = true; + } + // #101: merge the discovered slash commands (available_commands_update). + if !disc.slash_commands.is_empty() { + caps.slash_commands = disc.slash_commands.clone(); + } + caps + } +} + +impl AcpSessionBackend { + pub fn session_id(&self) -> &str { + &self.session_id + } +} + +impl Drop for AcpSessionBackend { + /// M5: abort the live reader (via the controller's mirrored AbortHandle, no + /// await) so its `Arc` clone is released and the ACP subprocess is + /// reaped (kill_on_drop). ACP CLIs/bridges are persistent (stdout never EOFs + /// mid-session), so without this the reader would block forever on + /// `next_line()`, orphaning the child. Also stop the idle timer if running. + fn drop(&mut self) { + self.suspend.abort_on_drop(); + if let Some(timer) = &self.idle_timer { + timer.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::{McpServerSpec, McpTransport}; + use crate::testing::FakeAgentIo; + + /// PROPERTY (§F.3 input field-value boundary for the acp `map_update` entry, + /// sibling of codex `prop_map_item_*` and claude `prop_parse_assistant_*`): for + /// ANY `session/update` params shape — arbitrary `sessionUpdate` kind (known / + /// unknown / absent) and arbitrary payload — `map_update`: + /// 1. NEVER panics (the ACP update is matched on the kind STRING, never a closed + /// SDK enum, so future/malformed variants are data not a crash — A1); + /// 2. an UNKNOWN kind → exactly an `AdapterSpecific{tag:"acp_update:"}`. + /// + /// Sweeps the kind value space directly (map_update is a near-pure async fn over + /// &Value + two default-constructible state holders). + #[test] + fn prop_map_update_never_panics_unknown_kind_is_adapter_specific() { + use proptest::prelude::*; + const KNOWN: &[&str] = &[ + "agent_message_chunk", + "agent_thought_chunk", + "tool_call", + "tool_call_update", + "usage_update", + "current_mode_update", + "current_model_update", + "available_commands_update", + "plan", + "session_info_update", + ]; + let unknown = "[a-z][a-z_]{0,14}".prop_filter("unknown", |s| !KNOWN.contains(&s.as_str())); + let kind_strat = prop_oneof![ + Just(None), + prop::sample::select(KNOWN).prop_map(|s| Some(s.to_string())), + unknown.prop_map(Some), + ]; + let rt = tokio::runtime::Runtime::new().unwrap(); + + proptest!(|(kind in kind_strat)| { + let mut update = serde_json::json!({"blob": 7}); + if let Some(k) = &kind { update["sessionUpdate"] = serde_json::Value::String(k.clone()); } + let params = serde_json::json!({"sessionId": "s", "update": update}); + let model = Arc::new(Mutex::new(None)); + let disc = Arc::new(std::sync::Mutex::new(Discovered::default())); + + let events = rt.block_on(map_update(¶ms, &model, &disc)); // (1) must not panic + + if let Some(k) = &kind + && !k.is_empty() + && !KNOWN.contains(&k.as_str()) + { + prop_assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::AdapterSpecific { tag, .. } if tag == &format!("acp_update:{k}") + )), + "unknown sessionUpdate {k:?} must surface as AdapterSpecific, got {events:?}" + ); + } + }); + } + + // ── Wave 0c: MCP injection into session/new + session/load ── + // + // The pre-0c regression: both params functions hardcoded `mcpServers: []`, so + // a clean-slate ACP session dropped EVERY user/guide/team MCP server (a fresh + // session AND a resumed one). These pin the fix: the resolved MCP servers from + // SessionConfig.init are serialized into the session/new + session/load frames, + // byte-identical to the ACP SDK `McpServer` wire shape (verified empirically: + // Stdio is untagged {name,command,args,env:[{name,value}]}). + + fn cfg_with_mcp() -> SessionConfig { + SessionConfig { + cwd: Some("/work".into()), + init: crate::backend::SessionInit { + mcp_servers: vec![ + McpServerSpec { + name: "fs".into(), + transport: McpTransport::Stdio { + command: "/usr/bin/node".into(), + args: vec!["server.js".into()], + env: vec![("TOKEN".into(), "x".into())], + }, + }, + McpServerSpec { + name: "remote".into(), + transport: McpTransport::Http { + url: "https://mcp.example/api".into(), + headers: vec![("Authorization".into(), "Bearer y".into())], + }, + }, + ], + ..Default::default() + }, + ..Default::default() + } + } + + #[test] + fn new_session_params_injects_resolved_mcp_servers() { + let params = new_session_params(&cfg_with_mcp()); + assert_eq!(params["cwd"], "/work"); + let servers = params["mcpServers"].as_array().expect("mcpServers array"); + assert_eq!( + servers.len(), + 2, + "both resolved servers are injected (was hardcoded [])" + ); + // Stdio: untagged {name,command,args,env:[{name,value}]} — SDK byte-parity. + assert_eq!(servers[0]["name"], "fs"); + assert_eq!(servers[0]["command"], "/usr/bin/node"); + assert_eq!(servers[0]["args"][0], "server.js"); + assert_eq!(servers[0]["env"][0]["name"], "TOKEN"); + assert_eq!(servers[0]["env"][0]["value"], "x"); + assert!(servers[0].get("type").is_none(), "Stdio is untagged (no type field)"); + // Http: {type:http,name,url,headers:[{name,value}]}. + assert_eq!(servers[1]["type"], "http"); + assert_eq!(servers[1]["url"], "https://mcp.example/api"); + assert_eq!(servers[1]["headers"][0]["name"], "Authorization"); + } + + #[test] + fn parse_generic_config_option_ids_excludes_mode_and_model() { + // Wire-pinned to the claude-acp 0.33.1 shape (mode + model + effort). + let opts = json!([ + { "id": "mode", "category": "mode", "type": "select", "currentValue": "default", "options": [] }, + { "id": "model", "category": "model", "type": "select", "currentValue": "opus", "options": [] }, + { "id": "effort", "category": "thought_level", "type": "select", "currentValue": "xhigh", "options": [] }, + ]); + let ids = parse_generic_config_option_ids(Some(&opts)); + assert_eq!(ids, vec!["effort"], "mode/model excluded (dedicated arms); effort kept"); + assert!(parse_generic_config_option_ids(None).is_empty()); + // Only mode/model → no generic options. + let only_mm = json!([{ "id": "mode" }, { "id": "model" }]); + assert!(parse_generic_config_option_ids(Some(&only_mm)).is_empty()); + } + + /// opencode 1.16.2: model/mode are inside `configOptions[]` (NOT top-level), each + /// `{id, currentValue, options:[{value,name,description}]}`. `config_option_select` + /// extracts the options + currentValue so `handle_open_response` can fill + /// disc.models/modes (the fix for "config-options empty → set_model -32602"). + #[test] + fn config_option_select_extracts_opencode_model_and_mode() { + // The LIVE opencode shape (value = provider-prefixed full id). + let opts = json!([ + { "id": "model", "category": "model", "type": "select", "currentValue": "opencode/big-pickle", + "options": [ + { "value": "amazon-bedrock/anthropic.claude-opus-4-8", "name": "Claude Opus 4.8" }, + { "value": "amazon-bedrock/openai.gpt-5.5", "name": "GPT-5.5", "description": "fast" }, + ]}, + { "id": "mode", "category": "mode", "type": "select", "currentValue": "build", + "options": [ + { "value": "build", "name": "build" }, + { "value": "plan", "name": "plan", "description": "Plan mode." }, + ]}, + ]); + let (models, cur_model) = config_option_select(Some(&opts), "model").expect("model option present"); + assert_eq!( + cur_model.as_deref(), + Some("opencode/big-pickle"), + "currentValue → current_model" + ); + assert_eq!(models.len(), 2); + assert_eq!( + models[1].0, "amazon-bedrock/openai.gpt-5.5", + "value → id (the token set_model sends back)" + ); + assert_eq!(models[1].1, "GPT-5.5"); + assert_eq!(models[1].2.as_deref(), Some("fast")); + + let (modes, cur_mode) = config_option_select(Some(&opts), "mode").expect("mode option present"); + assert_eq!(cur_mode.as_deref(), Some("build")); + assert_eq!( + modes.iter().map(|m| m.0.as_str()).collect::>(), + vec!["build", "plan"] + ); + + // Absent / wrong shape → None (so the top-level path / empty fallback applies). + assert!(config_option_select(Some(&opts), "effort").is_none()); + assert!(config_option_select(None, "model").is_none()); + } + + /// handle_open_response dual-shape: an opencode-style session/new (model/mode in + /// configOptions[], NO top-level models/modes) fills disc.models/modes + current_*, + /// AND a claude-acp-style top-level result still parses (regression). This is the + /// end-to-end fix for the opencode "empty config-options" prod bug. + #[tokio::test] + async fn handle_open_response_parses_both_opencode_configoptions_and_toplevel() { + use std::sync::Mutex as StdMutex; + let mk = || { + ( + Arc::new(AtomicU64::new(1)), + broadcast::channel::(64).0, + Arc::new(Mutex::new(None::)), + Arc::new(StdMutex::new(Discovered::default())), + ) + }; + + // (1) opencode shape: configOptions[] carries model + mode, no top-level keys. + let (tg, tx, sid, disc) = mk(); + let opencode_result = json!({ + "sessionId": "oc-1", + "configOptions": [ + { "id": "model", "currentValue": "opencode/big-pickle", + "options": [{ "value": "amazon-bedrock/openai.gpt-5.5", "name": "GPT-5.5" }] }, + { "id": "mode", "currentValue": "build", + "options": [{ "value": "build", "name": "build" }, { "value": "plan", "name": "plan" }] }, + ] + }); + handle_open_response( + &opencode_result, + "s", + &tg, + &tx, + &sid, + &Arc::new(Mutex::new(None)), + &disc, + ) + .await; + { + let d = disc.lock().unwrap(); + assert_eq!(d.models.len(), 1, "opencode model extracted from configOptions"); + assert_eq!(d.models[0].id, "amazon-bedrock/openai.gpt-5.5"); + assert_eq!(d.current_model.as_deref(), Some("opencode/big-pickle")); + assert_eq!( + d.modes.iter().map(|m| m.id.as_str()).collect::>(), + vec!["build", "plan"] + ); + assert_eq!(d.current_mode.as_deref(), Some("build")); + } + + // (2) claude-acp shape: top-level models/modes (regression — must still parse). + let (tg, tx, sid, disc) = mk(); + let claude_result = json!({ + "sessionId": "cl-1", + "models": { "currentModelId": "opus", "availableModels": [{ "modelId": "opus", "name": "Opus" }] }, + "modes": { "currentModeId": "default", "availableModes": [{ "id": "default", "name": "Default" }] }, + }); + handle_open_response(&claude_result, "s", &tg, &tx, &sid, &Arc::new(Mutex::new(None)), &disc).await; + { + let d = disc.lock().unwrap(); + assert_eq!(d.models[0].id, "opus", "top-level model still parses"); + assert_eq!(d.current_model.as_deref(), Some("opus")); + assert_eq!(d.modes[0].id, "default"); + } + } + + #[tokio::test] + async fn set_config_option_writes_frame_only_for_advertised_generic_option() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend = AcpSessionBackend::build_with_io("s", Box::new(fake)).await; + backend.bind_for_test("acp-sid").await; + // An UNADVERTISED option rejects (cap-behavior invariant: advertised ⟺ settable). + let rejected = backend + .dispatch(Command::SetConfigOption { + option_id: "effort".into(), + value: "low".into(), + }) + .await; + assert!( + matches!(rejected, Err(BackendError::CommandNotSupported { command }) if command == "set_config_option"), + "an unadvertised option must reject, got {rejected:?}" + ); + // Discover `effort` (as the session/new response would) → now it is settable. + { + let mut disc = backend.discovered.lock().unwrap_or_else(|e| e.into_inner()); + disc.config_options = vec!["effort".into()]; + } + backend + .dispatch(Command::SetConfigOption { + option_id: "effort".into(), + value: "low".into(), + }) + .await + .expect("advertised option dispatches"); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let raw = String::from_utf8(captured.lock().await.clone()).unwrap(); + let frame = raw + .lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .find(|f| f["method"] == "session/set_config_option") + .expect("a session/set_config_option frame was written"); + assert_eq!(frame["params"]["optionId"], "effort"); + assert_eq!(frame["params"]["value"], "low"); + assert_eq!(frame["params"]["sessionId"], "acp-sid"); + } + + /// Acknowledge (user-ack of a done-unseen turn) has NO ACP wire — it folds at + /// the conversation read layer. It must accept as NoTurn and write nothing. + /// (acp was the only backend whose Acknowledge arm lacked a dedicated test; + /// claude/codex already have one — closes acp's per-arm dispatch coverage.) + #[tokio::test] + async fn dispatch_acknowledge_is_local_noop_no_wire() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend = AcpSessionBackend::build_with_io("s", Box::new(fake)).await; + backend.bind_for_test("acp-sid").await; + + let receipt = backend + .dispatch(Command::Acknowledge { + node_id: "node-1".into(), + }) + .await + .expect("Acknowledge is always accepted (never CommandNotSupported)"); + assert_eq!( + receipt.admission, + Admission::NoTurn, + "Acknowledge folds at the conversation layer; it must not open a turn" + ); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let raw = String::from_utf8(captured.lock().await.clone()).unwrap(); + assert!( + raw.is_empty() || !raw.contains(r#""method""#), + "Acknowledge must write NO ACP request frame, got: {raw}" + ); + } + + #[test] + fn reconcile_plan_aligns_only_mismatched_advertised_dimensions() { + // Both differ + advertised → both set commands, mode before model. + let plan = reconcile_plan(true, Some("plan"), Some("default"), true, Some("opus"), Some("sonnet")); + assert_eq!(plan.len(), 2); + assert!(matches!(&plan[0], Command::SetMode { mode } if mode == "plan")); + assert!(matches!(&plan[1], Command::SetModel { model } if model == "opus")); + + // Observed already matches desired → no command (idempotent re-open). + assert!(reconcile_plan(true, Some("plan"), Some("plan"), true, Some("opus"), Some("opus")).is_empty()); + + // Only the model drifted → only SetModel. + let plan = reconcile_plan(true, Some("plan"), Some("plan"), true, Some("opus"), Some("sonnet")); + assert_eq!(plan.len(), 1); + assert!(matches!(&plan[0], Command::SetModel { model } if model == "opus")); + + // Dimension NOT advertised → never reconciled, even on mismatch (a mode-less + // agent must not get a spurious set_mode). + assert!( + reconcile_plan( + false, + Some("plan"), + Some("default"), + false, + Some("opus"), + Some("sonnet") + ) + .is_empty() + ); + + // No desired value → nothing to align to (the agent's own default stands). + assert!(reconcile_plan(true, None, Some("default"), true, None, Some("sonnet")).is_empty()); + + // Advertised + desired but observed=None (agent reported no current) → still + // align to desired (the resumed agent came up without echoing a current). + let plan = reconcile_plan(true, Some("plan"), None, false, None, None); + assert_eq!(plan.len(), 1); + assert!(matches!(&plan[0], Command::SetMode { mode } if mode == "plan")); + } + + #[test] + fn enrich_acp_error_appends_stderr_cause_for_generic_message_only() { + let stderr = "ERROR codex_acp::thread: You've hit your usage limit, try again later"; + // Generic message → enriched with the allowlisted stderr cause. + let enriched = enrich_acp_error_message("internal error", Some(stderr)); + assert!( + enriched.starts_with("internal error ("), + "generic message is enriched; got {enriched}" + ); + assert!( + enriched.contains("usage limit"), + "the allowlisted cause is appended; got {enriched}" + ); + // Specific message → unchanged (no spurious enrichment). + let specific = enrich_acp_error_message("Tool 'foo' is not allowed in plan mode", Some(stderr)); + assert_eq!(specific, "Tool 'foo' is not allowed in plan mode"); + // Generic message but no allowlisted stderr → unchanged (S0 returns None). + let no_cause = enrich_acp_error_message("internal error", Some("DEBUG some unrelated line")); + assert_eq!(no_cause, "internal error"); + // No stderr at all → unchanged. + assert_eq!(enrich_acp_error_message("internal error", None), "internal error"); + } + + #[test] + fn is_generic_error_message_classifies_known_generic_strings() { + assert!(is_generic_error_message("internal error")); + assert!(is_generic_error_message("Internal Server Error")); + assert!(is_generic_error_message("ACP prompt failed")); + assert!(is_generic_error_message(" unknown ")); + assert!(is_generic_error_message("")); + assert!(!is_generic_error_message("rate limit exceeded")); + assert!(!is_generic_error_message("session not found")); + } + + #[test] + fn synth_turn_result_error_frame_carries_enriched_message() { + // A generic JSON-RPC error response + an allowlisted stderr cause → the + // error TurnResult's result_text surfaces the cause (G1-B end-to-end). + let frame = json!({ "jsonrpc": "2.0", "id": 1, "error": { "code": -32603, "message": "internal error" } }); + let stderr = "ERROR codex_acp: connection refused while contacting upstream"; + let ev = synth_turn_result(&frame, 3, Some(stderr)); + match ev { + SessionEvent::TurnResult { + is_error, result_text, .. + } => { + assert!(is_error, "an error frame is a failed terminal"); + assert!( + result_text.contains("connection refused"), + "stderr cause enriched in; got {result_text}" + ); + } + other => panic!("expected TurnResult, got {other:?}"), + } + // A success frame ignores stderr entirely (no enrichment, no peek needed). + let ok = json!({ "jsonrpc": "2.0", "id": 1, "result": { "stopReason": "end_turn" } }); + match synth_turn_result(&ok, 3, None) { + SessionEvent::TurnResult { is_error, .. } => assert!(!is_error), + other => panic!("expected TurnResult, got {other:?}"), + } + } + + #[test] + fn parse_permission_metadata_extracts_claude_title_prefix_server() { + // Claude-acp shape: server name rides the `mcp____` title. + let params = json!({ + "options": [ + { "kind": "allow_always", "optionId": "allow_always", "name": "Always Allow" }, + { "kind": "reject_once", "optionId": "reject" }, + ], + "toolCall": { "title": "mcp__aionui-team__team_members", "rawInput": {} }, + }); + let meta = parse_permission_metadata(Some(¶ms)).expect("metadata present"); + assert_eq!(meta["server_name"], "aionui-team"); + assert_eq!(meta["options"][0]["option_id"], "allow_always"); + assert_eq!(meta["options"][0]["kind"], "allow_always"); + // CT-PERM-OPTIONS: the human label rides through for the card. + assert_eq!(meta["options"][0]["name"], "Always Allow"); + assert_eq!( + meta["options"][1]["name"], "", + "missing name → empty (finalizer falls back to id)" + ); + } + + #[test] + fn parse_permission_metadata_extracts_codex_raw_input_server() { + // Codex-acp shape: server name in toolCall.rawInput.server_name (wins over title). + let params = json!({ + "options": [{ "kind": "allow_once", "optionId": "allow" }], + "toolCall": { "title": "mcp__other__x", "rawInput": { "server_name": "aionui-team-guide" } }, + }); + let meta = parse_permission_metadata(Some(¶ms)).expect("metadata present"); + assert_eq!(meta["server_name"], "aionui-team-guide", "rawInput.server_name wins"); + } + + #[test] + fn parse_permission_metadata_none_when_no_server_and_no_options() { + // A non-MCP tool with no parseable options → None (a human decides; no card label). + let params = json!({ "toolCall": { "title": "Write file.txt", "rawInput": {} } }); + assert!(parse_permission_metadata(Some(¶ms)).is_none()); + assert!(parse_permission_metadata(None).is_none()); + } + + #[test] + fn load_session_params_reinjects_mcp_on_resume() { + // RESUME REGRESSION FIX: session/load must carry the SAME servers as + // session/new, else a resumed conversation silently loses all MCP tools. + let params = load_session_params("acp-sid-123", &cfg_with_mcp()); + assert_eq!(params["sessionId"], "acp-sid-123"); + assert_eq!(params["cwd"], "/work"); + let servers = params["mcpServers"].as_array().expect("mcpServers array"); + assert_eq!(servers.len(), 2, "resume re-injects MCP (was hardcoded [])"); + assert_eq!(servers[0]["name"], "fs"); + } + + #[test] + fn session_params_empty_mcp_is_byte_identical_to_pre_0c() { + // Default (no init) → mcpServers:[] — the pre-0c handshake is unchanged for + // conversations with no MCP configured. + let cfg = SessionConfig::default(); + assert_eq!(new_session_params(&cfg)["mcpServers"].as_array().unwrap().len(), 0); + assert_eq!( + load_session_params("s", &cfg)["mcpServers"].as_array().unwrap().len(), + 0 + ); + } + + /// M5 (codex/claude parity): dropping the backend aborts the reader so a + /// hung/persistent ACP process (no stdout EOF) is reaped. Inline because it + /// inspects the private `reader` handle. + #[tokio::test] + async fn dropping_backend_aborts_reader() { + let backend = AcpSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(Vec::new()))).await; + let handle = backend + .suspend + .current_abort_handle() + .expect("live reader has an abort handle"); + assert!(!handle.is_finished(), "reader live (blocked on read) before drop"); + drop(backend); + for _ in 0..40 { + if handle.is_finished() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!( + handle.is_finished(), + "dropping the backend aborts the reader (M5 parity)" + ); + } + + /// Wave 0c-F: the preset `[Assistant Rules]` block is prepended to the FIRST + /// `session/prompt` (ACP has no system-prompt field), and ONLY the first — a + /// second turn carries the user content verbatim. Drives two Sends over a + /// captured-stdin FakeAgentIo and parses the written prompt frames. + #[tokio::test] + async fn preset_preamble_prepended_to_first_prompt_only() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend = AcpSessionBackend::build_with_io("s", Box::new(fake)).await; + backend.bind_for_test("acp-sid").await; // so bound_session() resolves + backend + .set_pending_preamble_for_test("[Assistant Rules]\nBe terse.\n[/Assistant Rules]") + .await; + + for text in ["first", "second"] { + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text(text.into())], + metadata: super::super::types::CommandMeta::default(), + }) + .await + .expect("send dispatched"); + } + + // Parse the two session/prompt frames out of the captured stdin. Give the + // async frame writes a beat to flush into the captured buffer. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let raw = String::from_utf8(captured.lock().await.clone()).unwrap(); + let prompts: Vec = raw + .lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|f| f["method"] == "session/prompt") + .collect(); + assert_eq!(prompts.len(), 2, "two prompts written, got raw: {raw}"); + + // First prompt: leading [Assistant Rules] text block, THEN the user text. + let first = prompts[0]["params"]["prompt"].as_array().unwrap(); + assert_eq!(first[0]["type"], "text"); + assert!( + first[0]["text"].as_str().unwrap().contains("[Assistant Rules]"), + "first prompt leads with the preset block, got {:?}", + first[0] + ); + assert_eq!(first[1]["text"], "first", "user content follows the preamble"); + + // Second prompt: NO preamble — user content is the first block. + let second = prompts[1]["params"]["prompt"].as_array().unwrap(); + assert_eq!(second[0]["text"], "second", "second turn has no preamble"); + assert!( + !second + .iter() + .any(|b| b["text"].as_str().is_some_and(|t| t.contains("[Assistant Rules]"))), + "the preamble is applied exactly once" + ); + } + + /// G6 e2e: after open, a backend whose DESIRED mode/model differ from the agent's + /// OBSERVED current (a resumed agent comes up at its own default) re-aligns by + /// writing `session/set_mode` + `session/set_model` for the desired values. The + /// idempotent case (observed already matches) writes nothing — proven separately + /// in `reconcile_plan`'s unit test. + #[tokio::test] + async fn startup_reconcile_aligns_drifted_mode_and_model() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend = + AcpSessionBackend::build_with_io_and_desired("s", Box::new(fake), Some("plan".into()), Some("opus".into())) + .await; + backend.bind_for_test("acp-sid").await; // so dispatch(SetMode/SetModel)'s bound_session resolves + // The agent came up at its OWN default (drifted from desired). + backend + .seed_observed_for_test(Some("default".into()), Some("sonnet".into())) + .await; + // pending_open already None (build_with_io path) → reconcile sees the seeded observed. + let backend = Arc::new(backend); + backend.reconcile_startup_config().await; + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let raw = String::from_utf8(captured.lock().await.clone()).unwrap(); + let set_mode = raw + .lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .find(|f| f["method"] == "session/set_mode") + .expect("a session/set_mode was written"); + assert_eq!(set_mode["params"]["modeId"], "plan", "reconciled to the DESIRED mode"); + let set_model = raw + .lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .find(|f| f["method"] == "session/set_model") + .expect("a session/set_model was written"); + assert_eq!( + set_model["params"]["modelId"], "opus", + "reconciled to the DESIRED model" + ); + } + + /// G6 e2e (negative): when the agent's OBSERVED current already matches DESIRED, + /// the startup reconcile writes NOTHING (no spurious set_*). The complement of the + /// drift test above — together they pin "align iff mismatch". + #[tokio::test] + async fn startup_reconcile_noop_when_already_aligned() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend = + AcpSessionBackend::build_with_io_and_desired("s", Box::new(fake), Some("plan".into()), Some("opus".into())) + .await; + backend.bind_for_test("acp-sid").await; + backend + .seed_observed_for_test(Some("plan".into()), Some("opus".into())) + .await; + let backend = Arc::new(backend); + backend.reconcile_startup_config().await; + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let raw = String::from_utf8(captured.lock().await.clone()).unwrap(); + assert!( + !raw.contains("session/set_mode") && !raw.contains("session/set_model"), + "already-aligned session must not write any set_*; got: {raw}" + ); + } + + /// THE seam proof (codex parity): a real ACP turn — driven through + /// `Orchestrator::run()` — locks during the turn and unlocks + /// (`StateSnapshot.can_send=true`) at the terminal, with the SAME reducer/FSM + /// codex+claude use. Critically this exercises ACP's UNIQUE terminal: the + /// unlock comes from the `session/prompt` RESPONSE's `stopReason` (synthesized + /// into a TurnResult by the reader), NOT a notification — proving that + /// out-of-band terminal still folds through step() to the unlock. + #[tokio::test] + async fn acp_backend_folds_through_orchestrator_to_unlock() { + use super::super::Orchestrator; + use crate::state::SessionState; + use futures_util::StreamExt as _; + + // GATED turn tail: an agent_message_chunk delta (substantive output) then + // the session/prompt RESPONSE (id 1 — the first rpc id dispatch(Send) + // mints) carrying stopReason:end_turn (the ACP terminal). Gated until + // run() has subscribed + dispatch has registered pending_prompts[1]. + let tail = format!( + "{}\n{}\n", + r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}"#, + r#"{"jsonrpc":"2.0","id":1,"result":{"stopReason":"end_turn"}}"#, + ) + .into_bytes(); + let fake = FakeAgentIo::never_exits(Vec::new()).with_gated_tail(tail); + let release = fake.stdout_releaser(); + let backend = AcpSessionBackend::build_with_io("sess-acp", Box::new(fake)).await; + // Bind the ACP session id (the live path binds it from the session/new + // response; build_with_io skips the handshake). + backend.bind_for_test("acp-sid").await; + + let orch = std::sync::Arc::new(Orchestrator::new(256)); + let mut states = orch.subscribe_state("sess-acp"); + + // send() dispatches Send (writes session/prompt, registers pending_prompts[1]) + // and lowers TurnStarted (Idle→Running, can_send=false). + let receipt = orch + .send( + &backend, + "sess-acp", + vec![ContentBlock::Text("hi".into())], + super::super::types::CommandMeta::default(), + ) + .await + .expect("send accepted"); + assert_eq!(receipt.admission, Admission::Started); + + let run = { + let orch = orch.clone(); + tokio::spawn(async move { orch.run(&backend).await }) + }; + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + // Open the gate so the scripted delta + prompt-response terminal drive in. + release(); + + let unlocked = tokio::time::timeout(std::time::Duration::from_secs(5), async { + let mut saw_locked_running = false; + while let Some(snap) = states.next().await { + if snap.session_id != "sess-acp" { + continue; + } + if matches!(snap.state, SessionState::Running { .. }) && !snap.can_send { + saw_locked_running = true; + } + if matches!(snap.state, SessionState::Idle) && snap.can_send && saw_locked_running { + return true; + } + } + false + }) + .await + .expect("must not hang"); + + assert!( + unlocked, + "a real ACP turn folded through the orchestrator must lock during the turn and unlock \ + (can_send=true) at the session/prompt-response terminal" + ); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), run).await; + } + + /// 9a-ACP wire-out oracle: a connect-time `initialize` ERROR response (the + /// agent rejects the handshake — e.g. NOT logged in) is no longer silently + /// swallowed. The reader synthesizes an error `TurnResult` carrying the cause, + /// with the allowlisted stderr auth reason ENRICHED into the generic JSON-RPC + /// message (so the 9c classifier downstream → CheckAgentLogin). Pins the wire + /// (the emitted event), not just internal state — the missing `else` arm was a + /// silent-swallow that hung the first prompt opaquely. + #[tokio::test] + async fn connect_time_initialize_error_emits_enriched_error_terminal() { + use futures_util::StreamExt as _; + + // The initialize ERROR response (id 1 — set as pending_init below). Generic + // JSON-RPC message; the real auth cause is on stderr (the agent logs it + // there without echoing it in the error), so it must be enriched in. + let tail = + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32603,\"message\":\"internal error\"}}\n".to_vec(); + let fake = FakeAgentIo::never_exits(Vec::new()) + .with_gated_tail(tail) + .with_stderr("ERROR claude_acp: Invalid API key · please run /login (unauthorized)"); + let release = fake.stdout_releaser(); + let backend = AcpSessionBackend::build_with_io("sess-init-err", Box::new(fake)).await; + // The live path's run_handshake registers the initialize rpc id; build_with_io + // skips the handshake, so register it here to mirror the live reader claim. + backend.set_pending_init_for_test(1).await; + + let mut events = backend.events(); + // Subscribe BEFORE releasing so the synthesized terminal is observed. + release(); + + let ev = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::TurnResult { .. }) { + return Some(env.event); + } + } + None + }) + .await + .expect("must not hang") + .expect("a connect-time initialize error emits a TurnResult terminal"); + + match ev { + SessionEvent::TurnResult { + is_error, + result_text, + outcome, + .. + } => { + assert!(is_error, "connect-time initialize error is an error terminal"); + assert!( + matches!(outcome, TurnOutcome::Failed), + "outcome Failed, got {outcome:?}" + ); + assert!( + result_text.contains("Invalid API key") || result_text.contains("unauthorized"), + "the allowlisted stderr auth cause is enriched into the message, got: {result_text}" + ); + } + other => panic!("expected TurnResult, got {other:?}"), + } + } + + /// 9a-ACP wire-out oracle (the `session/new` arm): a connect-time + /// `session/new` ERROR (auth required / setup rejected) likewise synthesizes an + /// error terminal instead of being swallowed, AND clears `pending_open` so a + /// `bound_session()`/reconcile waiter unblocks immediately rather than spinning + /// out its retry window. + #[tokio::test] + async fn connect_time_session_new_error_emits_error_terminal_and_clears_pending_open() { + use futures_util::StreamExt as _; + + let tail = + b"{\"jsonrpc\":\"2.0\",\"id\":2,\"error\":{\"code\":-32000,\"message\":\"authentication required\"}}\n" + .to_vec(); + let fake = FakeAgentIo::never_exits(Vec::new()).with_gated_tail(tail); + let release = fake.stdout_releaser(); + let backend = AcpSessionBackend::build_with_io("sess-open-err", Box::new(fake)).await; + backend.set_pending_open_for_test(2).await; + + let mut events = backend.events(); + release(); + + let ev = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::TurnResult { .. }) { + return Some(env.event); + } + } + None + }) + .await + .expect("must not hang") + .expect("a connect-time session/new error emits a TurnResult terminal"); + + match ev { + SessionEvent::TurnResult { + is_error, result_text, .. + } => { + assert!(is_error, "connect-time session/new error is an error terminal"); + // Specific (non-generic) message is passed through verbatim (no stderr + // enrichment needed — is_generic_error_message is false for it). + assert!( + result_text.contains("authentication required"), + "the specific error message survives, got: {result_text}" + ); + } + other => panic!("expected TurnResult, got {other:?}"), + } + + // pending_open cleared → a waiter (bound_session / reconcile) is unblocked. + assert!( + backend.pending_open.lock().await.is_none(), + "the connect error clears pending_open so waiters don't spin out their window" + ); + } + + /// A `session/set_model`|`set_mode` JSON-RPC ERROR response (e.g. opencode + /// `-32602 model not found`) is surfaced as a `Notice{Warning}` (+ error log) + /// instead of being silently dropped — the prod bug where a FAILED set was + /// reported as a 200 command_ack and the selector never converged / no diagnosis. + #[tokio::test] + async fn acp_set_model_error_response_surfaces_notice_not_silent() { + // A gated tail carrying the error response for the set's rpc id (7). + let tail = concat!( + r#"{"jsonrpc":"2.0","id":7,"error":{"code":-32602,"message":"model not found: anthropic/claude-sonnet-4"}}"#, + "\n", + ) + .as_bytes() + .to_vec(); + let fake = FakeAgentIo::never_exits(Vec::new()).with_gated_tail(tail); + let release = fake.stdout_releaser(); + let backend = AcpSessionBackend::build_with_io("sess-set-err", Box::new(fake)).await; + backend + .set_pending_set_for_test(7, "model→anthropic/claude-sonnet-4") + .await; + + let mut events = backend.events(); + release(); + + let notice = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(env) = events.next().await { + if let SessionEvent::Notice { level, message } = env.event { + return Some((level, message)); + } + } + None + }) + .await + .expect("must not hang") + .expect("a set error must surface a Notice (not be silently dropped)"); + assert_eq!(notice.0, crate::event::NoticeLevel::Warning); + assert!( + notice.1.contains("model→anthropic/claude-sonnet-4") && notice.1.contains("model not found"), + "the Notice carries the label + agent error message, got: {}", + notice.1 + ); + // pending_set entry consumed (no leak). + assert!( + backend.pending_set.lock().await.is_empty(), + "the pending_set entry is claimed" + ); + } + + /// Protocol-audit fix (MED): AnswerPermission must echo a REAL offered optionId + /// chosen by KIND, not a hardcoded value. AllowAlways → the agent's allow_always + /// optionId (was "allow_once" → bridge rejected → tool silently DENIED); Denied → + /// the agent's reject_once optionId via {outcome:selected} (was {outcome:cancelled} + /// → client-abort path, not a clean reject). + #[tokio::test] + async fn acp_answer_permission_echoes_real_offered_option_id_by_kind() { + // A request_permission whose offered options use agent-specific ids ("ok"/"no") + // distinct from the kinds — proves we pick by KIND, not by guessing the id. + let req = concat!( + r#"{"jsonrpc":"2.0","id":501,"method":"session/request_permission","params":{"#, + r#""sessionId":"s","options":["#, + r#"{"optionId":"ok","kind":"allow_once","name":"Allow"},"#, + r#"{"optionId":"ok_always","kind":"allow_always","name":"Always Allow"},"#, + r#"{"optionId":"no","kind":"reject_once","name":"Reject"}],"#, + r#""toolCall":{"title":"Bash","rawInput":{}}}}"#, + "\n", + ) + .as_bytes() + .to_vec(); + let fake = FakeAgentIo::never_exits(Vec::new()).with_gated_tail(req); + let captured = fake.captured_stdin(); + let release = fake.stdout_releaser(); + let backend = AcpSessionBackend::build_with_io("s", Box::new(fake)).await; + let mut events = backend.events(); + release(); + + // Wait for the Permission event so its request_id (the wire "501") is surfaced + // and the offered options are stashed. + let req_id = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(env) = events.next().await { + if let SessionEvent::Permission { request_id, .. } = env.event { + return Some(request_id); + } + } + None + }) + .await + .expect("must not hang") + .expect("Permission surfaced"); + + // AllowAlways → must echo the real allow_always optionId "ok_always". + backend + .dispatch(Command::AnswerPermission { + request_id: req_id.clone(), + decision: PermissionDecision::AllowAlways, + selected: None, + answers: Vec::new(), + }) + .await + .expect("AnswerPermission(AllowAlways) accepted"); + // captured_stdin is drained by a background task — poll until the frame lands. + let written = { + let mut s = String::new(); + for _ in 0..40 { + s = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + if s.contains("outcome") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + s + }; + assert!( + written.contains(r#""optionId":"ok_always""#) && written.contains(r#""outcome":"selected""#), + "AllowAlways must echo the real allow_always optionId (ok_always), not a hardcoded id. got: {written}" + ); + assert!( + !written.contains(r#""optionId":"allow_once""#), + "must NOT send the fabricated allow_once id (bridges reject it). got: {written}" + ); + } + + /// Protocol-audit fix (MED): an image ContentBlock inside a tool_call_update + /// content item must be decoded to ToolResultContent::Image, not dropped (the + /// prior code read only `.text`, so an image-file Read / screenshot / vision tool + /// lost its bytes — the false "ACP never inlines image bytes" claim). + #[test] + fn parse_acp_tool_content_carries_image_block_not_just_text() { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode([1u8, 2, 3]); + let update = json!({ + "content": [ + { "type": "content", "content": { "type": "text", "text": "here is the screenshot" } }, + { "type": "content", "content": { "type": "image", "mimeType": "image/png", "data": b64 } }, + ] + }); + let content = parse_acp_tool_content(&update); + assert!( + content + .iter() + .any(|c| matches!(c, crate::event::ToolResultContent::Text(t) if t.contains("screenshot"))), + "text block still carried, got {content:?}" + ); + assert!( + content.iter().any(|c| matches!(c, + crate::event::ToolResultContent::Image { media_type, data } + if media_type == "image/png" && data == &[1u8, 2, 3])), + "image ContentBlock must decode to ToolResultContent::Image (was dropped), got {content:?}" + ); + } + + /// F-4 default: build_with_io → idle_ttl=None → never suspends (no timer, slot + /// Active for life). Protects the parse/dispatch contract from any F-4 cost. + #[tokio::test] + async fn f4_off_by_default_no_suspension() { + let backend = AcpSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(Vec::new()))).await; + assert!(backend.idle_timer.is_none(), "no idle timer when idle_ttl is None"); + assert!(backend.suspend.is_active().await, "slot Active"); + tokio::time::sleep(std::time::Duration::from_millis(60)).await; + assert!(backend.suspend.is_active().await, "stays Active (production parity)"); + } + + /// F-4 suspend→wake: a configured idle_ttl suspends the idle ACP CLI; the next + /// dispatch(Send) wakes by re-spawning the ACP command through the injected + /// spawner (then replaying session/load against the bound sid). FakeSpawner + /// records the spawn then Errs, so dispatch surfaces the wake error — the + /// hermetic proof the resume re-spawn ran with the configured command. + #[tokio::test] + async fn f4_suspend_then_wake_respawns_through_spawner() { + use crate::testing::FakeSpawner; + let spawner = Arc::new(FakeSpawner::new()); + let command = aionui_common::CommandSpec { + command: "hermes".into(), + args: vec!["acp".into()], + env: Vec::new(), + cwd: None, + }; + let backend = AcpSessionBackend::build_with_io_suspending( + "acp-resume-1", + Box::new(FakeAgentIo::never_exits(Vec::new())), + spawner.clone(), + command, + 40, + ) + .await; + // The resume anchor that survives the suspend (live path binds it from the + // session/new response; seed it here). + backend.bind_for_test("acp-sid-anchor").await; + assert!(backend.idle_timer.is_some(), "idle timer spawned when ttl is Some"); + + assert!( + backend + .suspend + .suspend_if_idle(aionui_common::now_ms() + 10_000, false) + .await + ); + assert!(!backend.suspend.is_active().await, "now Dormant"); + + let err = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("wake".into())], + metadata: super::super::types::CommandMeta::default(), + }) + .await + .expect_err("FakeSpawner cannot make a real process → wake Errs"); + assert!( + matches!(&err, BackendError::Transport(m) if m.contains("resume-spawn failed")), + "dispatch surfaced the wake re-spawn error, got {err:?}" + ); + assert_eq!(spawner.call_count(), 1, "wake routed through the injected spawner once"); + let spec = spawner.last_command().await.expect("a spawn was recorded"); + assert_eq!( + spec.command.to_str(), + Some("hermes"), + "wake re-spawns the configured ACP command" + ); + assert!( + spec.args.iter().any(|a| a == "acp"), + "wake re-spawns `hermes acp`, got {:?}", + spec.args + ); + drop(backend); + } + + // ====================================================================== + // Field-value coverage (2026-06-17 audit): two reachable session-layer + // field VALUES the field-value audit found unasserted — both produced by + // map_update, directly unit-testable here. + // ====================================================================== + + /// Field-value gap: `UsageDelta` with `input_tokens==0 && output_tokens==0`. + /// The ACP `usage_update` wire (hermes: `{used, size}`) carries only a + /// cumulative `used`, so map_update emits input=0/output=0/total=used — a real + /// production zero-token edge no test pinned (every other UsageDelta test uses + /// nonzero input/output). A regression that started defaulting input/output to + /// `used` (double-counting) would be caught here. + #[tokio::test] + async fn acp_usage_update_emits_zero_input_output_with_cumulative_total() { + let current_model = Arc::new(Mutex::new(None)); + let discovered = Arc::new(std::sync::Mutex::new(Discovered::default())); + let params = serde_json::json!({ + "update": { "sessionUpdate": "usage_update", "used": 4096, "size": 200000 } + }); + let events = map_update(¶ms, ¤t_model, &discovered).await; + assert_eq!(events.len(), 1, "usage_update → exactly one UsageDelta, got {events:?}"); + match &events[0] { + SessionEvent::UsageDelta { + input_tokens, + output_tokens, + total_tokens, + cost_usd, + } => { + assert_eq!( + *input_tokens, 0, + "hermes {{used,size}} shape carries no split → input==0" + ); + assert_eq!(*output_tokens, 0, "hermes shape carries no split → output==0"); + assert_eq!(*total_tokens, 4096, "cumulative `used` rides total_tokens"); + assert_eq!(*cost_usd, None, "the hermes shape carries no cost"); + } + other => panic!("expected UsageDelta, got {other:?}"), + } + } + + /// claude-agent-acp's RICHER usage_update shape — per-direction split + cost — must + /// be carried (was dropped: input/output ignored, cost hardcoded None). + #[tokio::test] + async fn acp_usage_update_carries_split_and_cost_when_present() { + let current_model = Arc::new(Mutex::new(None)); + let discovered = Arc::new(std::sync::Mutex::new(Discovered::default())); + let params = serde_json::json!({ + "update": { + "sessionUpdate": "usage_update", + "inputTokens": 1200, "outputTokens": 340, "totalTokens": 1540, + "cost": { "amount": 0.011, "currency": "USD" } + } + }); + let events = map_update(¶ms, ¤t_model, &discovered).await; + match events.first() { + Some(SessionEvent::UsageDelta { + input_tokens, + output_tokens, + total_tokens, + cost_usd, + }) => { + assert_eq!(*input_tokens, 1200); + assert_eq!(*output_tokens, 340); + assert_eq!(*total_tokens, 1540); + assert_eq!(*cost_usd, Some(0.011), "cost.amount must reach cost_usd (was dropped)"); + } + other => panic!("expected UsageDelta, got {other:?}"), + } + } + + /// The terminal session/prompt RESPONSE's result.usage → a UsageDelta (the + /// per-direction split that the streaming usage_update lacks). Was dropped. + #[test] + fn acp_result_usage_emits_terminal_usage_delta() { + let frame = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "result": { "stopReason": "end_turn", + "usage": { "inputTokens": 900, "outputTokens": 50, "totalTokens": 950, + "cost": { "amount": 0.007 } } } + }); + match parse_acp_result_usage(&frame) { + Some(SessionEvent::UsageDelta { + input_tokens, + output_tokens, + total_tokens, + cost_usd, + }) => { + assert_eq!((input_tokens, output_tokens, total_tokens), (900, 50, 950)); + assert_eq!(cost_usd, Some(0.007)); + } + other => panic!("expected UsageDelta, got {other:?}"), + } + // No usage in result → None (no spurious event). + let bare = serde_json::json!({ "result": { "stopReason": "end_turn" } }); + assert!(parse_acp_result_usage(&bare).is_none()); + } + + /// A `ConfigChanged { mode: None, model: Some(_) }` (model-only change) is real, + /// but it does NOT come from a `current_model_update` SessionUpdate — that variant + /// does not exist in ACP (schema 0.12.0 defines CurrentModeUpdate, never + /// CurrentModelUpdate; the model lives in SessionModelState inside session results). + /// A prior arm parsed `current_model_update` by guessed symmetry; it is removed. + /// This pins the HONEST behavior: a (non-existent) current_model_update frame falls + /// through to AdapterSpecific, NOT ConfigChanged. The REAL model-only ConfigChanged + /// is asserted by `acp_config_option_update_with_mode_emits_config_changed` (the + /// config_option_update path claude-acp actually uses, LIVE-VERIFIED). + #[tokio::test] + async fn acp_nonexistent_current_model_update_does_not_emit_config_changed() { + let current_model = Arc::new(Mutex::new(None)); + let discovered = Arc::new(std::sync::Mutex::new(Discovered::default())); + let params = serde_json::json!({ + "update": { "sessionUpdate": "current_model_update", "currentModelId": "gpt-5.5" } + }); + let events = map_update(¶ms, ¤t_model, &discovered).await; + assert!( + !events.iter().any(|e| matches!(e, SessionEvent::ConfigChanged { .. })), + "a `current_model_update` frame (a guessed, non-existent ACP variant) must NOT be parsed into a \ + ConfigChanged — it falls through to AdapterSpecific. Real model changes ride config_option_update." + ); + assert!( + events.iter().any( + |e| matches!(e, SessionEvent::AdapterSpecific { tag, .. } if tag.contains("current_model_update")) + ), + "the unknown sessionUpdate is preserved opaquely (lossless A1 catch-all), not silently dropped" + ); + } + + /// Some ACP agents (claude-agent-acp) report mode/model changes via + /// `config_option_update` (NOT current_mode/model_update). The wire below is the + /// REAL claude-acp shape captured live (acp_claude_bridge_set_mode_config_change_behavior): + /// `configOptions:[{id:"mode",currentValue:"plan"},{id:"model",currentValue:"default"},...]`. + /// The arm must extract the mode/model currentValue → ConfigChanged (else a + /// claude-acp mode switch is dropped and the frontend selector never updates — + /// README discipline #10). Generic option ids are still tracked + the full opaque + /// AdapterSpecific is still emitted. + #[tokio::test] + async fn acp_config_option_update_with_mode_emits_config_changed() { + let current_model = Arc::new(Mutex::new(None)); + let discovered = Arc::new(std::sync::Mutex::new(Discovered::default())); + // Real claude-acp wire (trimmed to the load-bearing fields). + let params = serde_json::json!({ + "update": { + "sessionUpdate": "config_option_update", + "configOptions": [ + { "id": "mode", "category": "mode", "type": "select", "currentValue": "plan", + "options": [{"value": "default"}, {"value": "plan"}] }, + { "id": "model", "category": "model", "type": "select", "currentValue": "opus", + "options": [{"value": "default"}, {"value": "opus"}] } + ] + } + }); + let events = map_update(¶ms, ¤t_model, &discovered).await; + let cc = events + .iter() + .find_map(|e| match e { + SessionEvent::ConfigChanged { mode, model } => Some((mode.clone(), model.clone())), + _ => None, + }) + .expect("config_option_update carrying mode/model must emit a ConfigChanged"); + assert_eq!(cc.0.as_deref(), Some("plan"), "mode.currentValue → ConfigChanged.mode"); + assert_eq!( + cc.1.as_deref(), + Some("opus"), + "model.currentValue → ConfigChanged.model" + ); + assert_eq!( + current_model.lock().await.as_deref(), + Some("opus"), + "config_option_update model also updates the tracked model" + ); + // The opaque catalog event is still emitted (additive, not replaced). + assert!( + events.iter().any( + |e| matches!(e, SessionEvent::AdapterSpecific { tag, .. } if tag.contains("config_option_update")) + ), + "the full options catalog still rides AdapterSpecific" + ); + } + + /// #101: an `available_commands_update` session/update fills the discovered + /// slash-command catalog (`update.availableCommands[{name, description}]` — + /// wire-pinned from hermes + claude-acp captures). It stays FSM-orthogonal + /// (AdapterSpecific event, no FSM signal), and `capabilities()` merges the catalog. + #[tokio::test] + async fn acp_available_commands_update_fills_slash_commands() { + let current_model = Arc::new(Mutex::new(None)); + let discovered = Arc::new(std::sync::Mutex::new(Discovered::default())); + let params = serde_json::json!({ + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { "name": "help", "description": "List available commands" }, + { "name": "model", "description": "Switch models", "input": { "hint": "model name" } }, + { "name": "reset" } + ] + } + }); + let events = map_update(¶ms, ¤t_model, &discovered).await; + // Event surface unchanged: opaque AdapterSpecific (no FSM signal). + assert!( + matches!(&events[..], [SessionEvent::AdapterSpecific { tag, .. }] if tag == "acp_update:available_commands_update"), + "available_commands_update stays an opaque AdapterSpecific, got {events:?}" + ); + // Catalog filled (name + optional description; a command without a description → None). + let cmds = discovered.lock().unwrap().slash_commands.clone(); + assert_eq!(cmds.len(), 3, "three commands parsed"); + assert_eq!(cmds[0].name, "help"); + assert_eq!(cmds[0].description.as_deref(), Some("List available commands")); + assert_eq!(cmds[2].name, "reset"); + assert_eq!(cmds[2].description, None, "a command without a description → None"); + } + + /// LC-8a: ACP `session/update{sessionUpdate:"plan"}` → `SessionEvent::Plan`. + /// entries[].{content,status,priority} direct map; snake_case `in_progress`→ + /// InProgress; priority parsed (high/medium/low). No explanation (codex-only). + #[tokio::test] + async fn acp_plan_update_maps_to_plan_event() { + use crate::event::{PlanPriority, PlanStatus}; + let current_model = Arc::new(Mutex::new(None)); + let discovered = Arc::new(std::sync::Mutex::new(Discovered::default())); + let params = serde_json::json!({ + "update": { + "sessionUpdate": "plan", + "entries": [ + { "content": "investigate", "status": "completed", "priority": "high" }, + { "content": "implement", "status": "in_progress", "priority": "medium" }, + { "content": "verify", "status": "pending" }, + ] + } + }); + let events = map_update(¶ms, ¤t_model, &discovered).await; + match &events[..] { + [SessionEvent::Plan { entries, explanation }] => { + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].content, "investigate"); + assert_eq!(entries[0].status, PlanStatus::Completed); + assert_eq!(entries[0].priority, Some(PlanPriority::High)); + assert_eq!( + entries[1].status, + PlanStatus::InProgress, + "snake_case in_progress normalized" + ); + assert_eq!(entries[1].priority, Some(PlanPriority::Medium)); + assert_eq!(entries[2].status, PlanStatus::Pending); + assert!(entries[2].priority.is_none(), "absent priority → None"); + assert!(explanation.is_none(), "ACP plan carries no explanation"); + } + other => panic!("expected one Plan event, got {other:?}"), + } + } + + /// Regression-by-rewrite (codex-500 TWIN): acp's bound_session had the IDENTICAL + /// hardcoded 2s busy-poll → bare Transport→500 bug, a downgrade from legacy ACP's + /// 30s. When session/new never binds, it must now return the RETRYABLE + /// HandshakeTimeout (not Transport). Tiny injected budget = deterministic. + #[tokio::test] + async fn bound_session_timeout_is_handshake_timeout_not_transport() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let backend = AcpSessionBackend::build_with_io("acp-noses", Box::new(fake)).await; + let err = backend + .bound_session_within(std::time::Duration::from_millis(120)) + .await + .expect_err("no session/new binding → must time out"); + assert!( + matches!(err, BackendError::HandshakeTimeout(_)), + "acp handshake timeout must be RETRYABLE HandshakeTimeout (not Transport→500), got {err:?}" + ); + } + + /// Positive: a late-arriving session binding (past the old 2s) within budget binds. + #[tokio::test] + async fn bound_session_binds_when_session_arrives_within_budget() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let backend = AcpSessionBackend::build_with_io("acp-late", Box::new(fake)).await; + backend.bind_for_test("s-late").await; // simulate session/new arriving + let sid = backend + .bound_session_within(std::time::Duration::from_secs(2)) + .await + .expect("a within-budget binding must succeed"); + assert_eq!(sid, "s-late"); + } + + /// Regression (opencode `-32602 session not found` on reopen): a `session/load` + /// RESPONSE carries NO `sessionId` (just config/null). The reader must still bind + /// the resume anchor — from `pending_resume_sid`, stashed by `run_handshake` + /// instead of pre-seeding `acp_session_id`. Without this the resume binding never + /// lands, OR (the original bug) it was pre-seeded so the first `session/prompt` + /// fired before opencode finished `session/load` → session-not-found. Binding only + /// on the response is what gates the prompt until the load completes. + #[tokio::test] + async fn session_load_response_binds_resume_sid_without_sessionid_in_result() { + let tg = Arc::new(AtomicU64::new(0)); + let (tx, mut rx) = broadcast::channel(8); + let acp_session_id: Arc>> = Arc::new(Mutex::new(None)); + // run_handshake's resume branch stashes the sid here (NOT into acp_session_id). + let pending_resume_sid: Arc>> = Arc::new(Mutex::new(Some("ses_resume_me".into()))); + let disc = Arc::new(std::sync::Mutex::new(Discovered::default())); + // A real opencode session/load result: configOptions, but NO `sessionId`. + let load_result = json!({ + "configOptions": [ + { "id": "mode", "currentValue": "build", + "options": [{ "value": "build", "name": "build" }] } + ] + }); + + // Before the response: acp_session_id is unbound, so bound_session() would block. + assert!( + acp_session_id.lock().await.is_none(), + "must NOT be pre-seeded before the load response" + ); + + handle_open_response(&load_result, "s", &tg, &tx, &acp_session_id, &pending_resume_sid, &disc).await; + + assert_eq!( + acp_session_id.lock().await.as_deref(), + Some("ses_resume_me"), + "the load response must bind the resume sid (taken from pending_resume_sid, since the result has none)" + ); + assert!( + pending_resume_sid.lock().await.is_none(), + "the resume sid is consumed one-shot" + ); + match rx.try_recv() { + Ok(env) => assert!( + matches!(env.event, SessionEvent::BackendBound { backend_session_id: Some(ref s) } if s == "ses_resume_me"), + "must emit BackendBound with the resume sid, got {:?}", + env.event + ), + Err(e) => panic!("expected a BackendBound event, got {e:?}"), + } + } + + /// Regression (opencode timer-bar on model change): a resumed ACP session + /// replays its FULL history as `session/update` notifications between the + /// `session/load` request and its RESPONSE. Those are historical — the frontend + /// renders history from conversation_blocks (the SSOT) — so the reader must + /// SUPPRESS them from the UI event stream while the resume load is in flight; + /// otherwise a warmup-triggered resume (e.g. picking a model on a cold conv) + /// streams the replay as live deltas → duplicate blocks + a spurious turn-active + /// timer bar. The suppression is UI-only: `map_update`'s metadata side-effects + /// (slash-command / config-option catalog into `discovered`) still run, and once + /// the load RESPONSE takes the resume sid the window closes and live deltas flow. + #[tokio::test] + async fn session_load_replay_is_suppressed_from_ui_but_metadata_survives() { + use futures_util::StreamExt as _; + + // Scripted tail (reader is the single ordered consumer, so order holds): + // 1. replayed assistant text → MessageDelta (MUST be suppressed) + // 2. available_commands_update → fills discovered.slash_commands (side-effect + // MUST survive) + AdapterSpecific (MUST be suppressed) + // 3. session/load RESPONSE id:1 → binds resume sid, emits BackendBound, + // takes pending_resume_sid → CLOSES the replay window (ordering sentinel) + // 4. post-load assistant text → MessageDelta (MUST now be emitted) + let tail = format!( + "{}\n{}\n{}\n{}\n", + r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"REPLAYED-HISTORY"}}}}"#, + r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"shrink context"}]}}}"#, + r#"{"jsonrpc":"2.0","id":1,"result":{"configOptions":[{"id":"mode","currentValue":"build","options":[{"value":"build","name":"build"}]}]}}"#, + r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LIVE-DELTA"}}}}"#, + ) + .into_bytes(); + let fake = FakeAgentIo::never_exits(Vec::new()).with_gated_tail(tail); + let release = fake.stdout_releaser(); + let backend = AcpSessionBackend::build_with_io("sess-replay", Box::new(fake)).await; + // Open the resume-load window (run_handshake's Resume branch does this live) + // and register the load-response rpc id so id:1 hits handle_open_response. + backend.set_pending_resume_sid_for_test("ses_resume_me").await; + backend.set_pending_open_for_test(1).await; + + let mut events = backend.events(); + release(); + + // Collect until the LIVE post-load delta arrives; assert the REPLAYED delta + // and the replayed AdapterSpecific never surfaced, and BackendBound preceded + // the live delta (the window closed exactly at the load response). + let (saw_replay_ui, saw_backend_bound_before_live) = + tokio::time::timeout(std::time::Duration::from_secs(5), async { + let mut saw_replay = false; + let mut saw_bound = false; + while let Some(env) = events.next().await { + match &env.event { + SessionEvent::MessageDelta { text, .. } if text == "REPLAYED-HISTORY" => { + saw_replay = true; + } + SessionEvent::AdapterSpecific { tag, .. } if tag == "acp_update:available_commands_update" => { + // The replayed metadata's opaque event must also be suppressed. + saw_replay = true; + } + SessionEvent::BackendBound { .. } => saw_bound = true, + SessionEvent::MessageDelta { text, .. } if text == "LIVE-DELTA" => { + return (saw_replay, saw_bound); + } + _ => {} + } + } + (saw_replay, saw_bound) + }) + .await + .expect("must not hang; the live post-load delta must arrive"); + + assert!( + !saw_replay_ui, + "the session/load history replay must be suppressed from the UI event stream" + ); + assert!( + saw_backend_bound_before_live, + "the load response (BackendBound) must close the replay window before the live delta" + ); + // The metadata side-effect must have survived the suppression. + let cmds = backend + .discovered + .lock() + .unwrap_or_else(|e| e.into_inner()) + .slash_commands + .clone(); + assert!( + cmds.iter().any(|c| c.name == "compact"), + "replayed available_commands_update must still fill discovered.slash_commands \ + (suppression is UI-only), got {cmds:?}" + ); + } +} diff --git a/crates/aionui-session/src/backend/claude_conn.rs b/crates/aionui-session/src/backend/claude_conn.rs new file mode 100644 index 000000000..bf3aa10fc --- /dev/null +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -0,0 +1,5524 @@ +//! 007 §C5 (claude variant): `ClaudeConnection` / `ClaudeSessionBackend` — the +//! NEW symmetric-seam impl that WRAPS the existing `ClaudeAdapter` spawn+parse +//! logic so its behavior is verbatim-unchanged (claude is already in production; +//! the hard acceptance is "parse output zero-diff"). This is the strangler's +//! claude lane: the legacy `adapter`/`run_turn` path stays compiled in parallel +//! behind the `legacy-session` feature; the orchestrator selects this path. +//! +//! Shape: claude is a 1:1 connection→session backend (one spawned process per +//! session, no multiplexing). A long-lived reader task drains the persistent +//! process's stdout, feeds bytes through `ClaudeAdapter::parse_chunk`, stamps +//! the live `turn_gen`, wraps each event in a `SessionEnvelope`, and broadcasts +//! it on `events()`. `dispatch(Send)` delivers the prompt over the retained +//! stdin + flush, bumps `turn_gen`, and synthesizes `PromptAccepted` +//! (Synthesized — claude has no native prompt-ack wire signal). + +use std::sync::Arc; + +use aionui_process::Spawner; +use tokio::sync::{Mutex, broadcast}; + +use super::suspend::{ProcHandle, SuspendController, spawn_idle_timer}; +use super::types::{ + Admission, BackendError, CancelTarget, Command, CommandReceipt, PendingPermissionView, SessionEnvelope, SessionSpec, +}; +use super::{BackendConnection, SessionBackend, SessionConfig}; +use crate::adapter::{AgentIo, BackendAdapter, ClaudeAdapter, SessionSpec as LegacySessionSpec}; +use crate::capability::Capabilities; +use crate::event::SessionEvent; +use futures_util::stream::{BoxStream, StreamExt}; + +/// Connection-level factory for claude. Holds the injected `Spawner` (the only +/// way to spawn — never raw `Command`, S14) + a default `SessionConfig`. claude +/// is 1:1, so `open_session` spawns one process and returns one backend handle. +pub struct ClaudeConnection { + spawner: Arc, +} + +impl ClaudeConnection { + pub fn new(spawner: Arc) -> Self { + Self { spawner } + } + + /// Map the two-id `SessionSpec` (§4.1) to `(logical_id, claude_session_id, + /// legacy_spec)`: + /// - `logical_id` — our demux key, stamped on every envelope; the backend's + /// `session_id`. Often a prefixed id (`conv_`) — NOT a bare UUID. + /// - `claude_session_id` — the bare-UUID id claude is spawned with + /// (`--session-id`) and resumed with (`--resume`). MUST be a valid UUID or + /// claude exits 1 `Invalid session ID` (see [`claude_session_id_for`]). + /// - `legacy_spec` — what `ClaudeAdapter::start_turn` uses for the initial + /// spawn (Fresh `--session-id` / Resume `--resume`). + /// + /// On a lost-backend Resume (`backend_session_id: None`) we rebind a FRESH + /// valid-UUID claude session (the old on-disk session is gone). + fn to_legacy_spec(spec: &SessionSpec) -> (String, String, LegacySessionSpec) { + match spec { + SessionSpec::Fresh { session_id } => { + let claude_id = claude_session_id_for(session_id); + ( + session_id.clone(), + claude_id.clone(), + LegacySessionSpec::Fresh(claude_id), + ) + } + SessionSpec::Resume { + session_id, + backend_session_id, + } => match backend_session_id { + // claude echoed this id in `system/init` (BackendBound) so it is + // already a valid UUID — resume it verbatim. + Some(bid) => (session_id.clone(), bid.clone(), LegacySessionSpec::Resume(bid.clone())), + // lost backend session → rebind a fresh valid-UUID claude session. + None => { + let claude_id = claude_session_id_for(session_id); + ( + session_id.clone(), + claude_id.clone(), + LegacySessionSpec::Fresh(claude_id), + ) + } + }, + } + } +} + +/// Derive the bare-UUID id to spawn/resume claude with (`--session-id` / +/// `--resume`). claude REQUIRES a valid UUID here: a non-UUID makes it exit code +/// 1 with `Error: Invalid session ID. Must be a valid UUID.` (the message lands +/// on stderr, which the `Detached` event does not surface, so it looks like an +/// empty silent crash → `Error{Crashed}`). +/// +/// Our logical session id is the conversation id (`conv_` — prefixed, +/// NOT a bare UUID), so the seam must mint one rather than forward it verbatim. +/// If the logical id already parses as a UUID (the F1 factory mints a bare +/// `Uuid::new_v4()` upstream, so production ids pass through UNCHANGED), use it +/// as-is; otherwise mint a fresh v4. claude echoes whichever id it was given in +/// `system/init` → `BackendBound` → persisted as `backend_session_id`, so the +/// minted id becomes the cross-process resume anchor and the wake recipe resumes +/// the SAME id (decoupling the on-disk claude id from the logical demux key, +/// §4.1). +fn claude_session_id_for(logical_id: &str) -> String { + match uuid::Uuid::parse_str(logical_id) { + Ok(_) => logical_id.to_string(), + Err(_) => uuid::Uuid::new_v4().to_string(), + } +} + +/// Prepend `head` flags before `tail`, returning a new owned arg vec. Used so the +/// init-surface flags are positioned before any caller-supplied `extra_args` (a +/// caller flag that duplicates one then wins by appearing later on the CLI). +fn prepend_args(head: &[String], tail: &[String]) -> Vec { + let mut out = Vec::with_capacity(head.len() + tail.len()); + out.extend_from_slice(head); + out.extend_from_slice(tail); + out +} + +/// Translate the neutral [`SessionConfig`] init surface into claude CLI flags +/// (S18/D13 parity with the legacy F1 `prelude.rs`, which is NOT on the clean-slate +/// route). Each flag is omitted when its source is empty, so a default/empty config +/// produces no flags (pre-0c spawn byte-identical): +/// - `init.mcp_servers` → `--mcp-config ` + `--strict-mcp-config` (the latter +/// ONLY alongside `--mcp-config`: it makes the session ignore the machine's +/// ambient `~/.claude` servers, which we must NOT do when we inject none). +/// - `init.preset_context` → `--system-prompt` (composed `[Assistant Rules]` / +/// skills index / team-guide text, already assembled by the app boundary). +/// - `model` → `--model`; `mode` → `--permission-mode` (claude has no in-band +/// switch at spawn; a UI switch persists + evicts so the rebuild re-applies here). +/// +/// claude's `--mcp-config` uses a MAP shape `{"mcpServers":{"":{…}}}` (NOT the +/// ACP array), so this builds its own JSON rather than reusing `acp_conn`'s array +/// serializer. stdio → `{command,args,env:{k:v}}`; http/sse → `{type,url,headers:{…}}`. +pub(crate) fn build_claude_init_args(config: &SessionConfig) -> Vec { + let mut args = Vec::new(); + + if let Some(preset) = config + .init + .preset_context + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + args.push("--system-prompt".to_string()); + args.push(preset.to_string()); + } + + if let Some(mcp_json) = build_claude_mcp_config(&config.init.mcp_servers) { + args.push("--mcp-config".to_string()); + args.push(mcp_json); + args.push("--strict-mcp-config".to_string()); + } + + // SECURITY (fail-CLOSED): ALWAYS pass --permission-mode. Omitting it makes claude + // headless default to `bypassPermissions` — LIVE-PROBED: `system/init` reports + // `permissionMode: bypassPermissions` and Write/Bash auto-run with NO `can_use_tool` + // prompt. config.mode is `None` for an ordinary claude session (the create path + // does not seed it; an interactive switch is in-band + persisted to extra, read + // back into config.mode on the next spawn), so gating the flag on `Some` silently + // downgraded every default session to the most-permissive mode. Default to + // "default" (standard prompts) so a session with no explicit choice is gated, not + // bypassed. `default`/`acceptEdits`/`bypassPermissions`/`plan`/`dontAsk`/`auto` are + // claude's exact accepted wire values — the whitelist is a SUPERSET of the advertised + // picker (which omits `auto`; see `claude_permission_modes`) so a resumed session that + // carries `auto` is not downgraded/crashed. + // VALIDATE before the flag reaches the spawn: an invalid `--permission-mode` + // makes claude exit 1 at spawn (LIVE-PROBED), which surfaces as an opaque + // "agent crashed" with no diagnosis. `config.mode` is sourced from unconstrained + // storage (a persisted `current_mode_id`, an assistant default), so a stale/ + // generic alias that survived normalization would harden into a spawn crash. The + // dead-until-now `is_valid_claude_permission_mode` is the exact seed-time + // whitelist for this; an unrecognized value falls back to the fail-CLOSED + // "default" (a WARN records the drop) rather than crashing the process. Mirrors + // the ACP path's `clear_invalid_desired_mode` (drop-if-not-in-catalog) — a + // protection the port had wired but never called. + let mode = config + .mode + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .filter(|m| { + let ok = crate::adapter::is_valid_claude_permission_mode(m); + if !ok { + tracing::warn!( + requested_mode = %m, + "claude: ignoring unrecognized --permission-mode (would crash spawn); \ + falling back to \"default\"" + ); + } + ok + }) + .unwrap_or("default"); + args.push("--permission-mode".to_string()); + args.push(mode.to_string()); + + // UNLOCK runtime bypass WITHOUT architecting away the spawn-time mode. claude has + // TWO distinct flags (LIVE-PROBED 2.1.185): + // --dangerously-skip-permissions FORCES init=bypassPermissions, OVERRIDING + // --permission-mode (would re-open the + // fail-open hole this fn closes — DO NOT use). + // --allow-dangerously-skip-permissions ONLY enables `bypassPermissions` as a + // reachable mode; it does NOT change the + // initial mode. With it + `--permission-mode + // default`, `default` still ENFORCES (Write + // prompts), AND a later in-band + // `set_permission_mode bypassPermissions` is + // ACCEPTED instead of rejected with "session + // was not launched with + // --dangerously-skip-permissions". + // Mirrors the official @agentclientprotocol/claude-agent-acp adapter, which passes + // the SDK's `allowDangerouslySkipPermissions` separately from `permissionMode`. + // Without this flag the user can never switch to bypass at runtime (claude rejects + // the in-band switch). bypass is unavailable as root (claude ignores it there); we + // pass the flag unconditionally and let the in-band control_response surface the + // rejection (the dispatch reconciles on the reply), keeping this builder a pure, + // syscall-free fn. + args.push("--allow-dangerously-skip-permissions".to_string()); + + // TEMPORARY: disable AskUserQuestion until the multi-question interactive card is + // ported to the current frontend. claude's AskUserQuestion can ask several + // questions at once (`{questions:[…]}`), but the active frontend only renders a + // single-question permission card, so a multi-question ask would silently drop all + // but the first. Rather than ship that half-answer behaviour, deny the tool at + // spawn time — claude then falls back to plain-text questions, which render fully. + // Mirrors the official @agentclientprotocol/claude-agent-acp adapter, which + // likewise lists `AskUserQuestion` in `disallowedTools` for the same reason + // ("not a great way to expose this over ACP at the moment"). Remove once the + // frontend gains a multi-question renderer. + args.push("--disallowed-tools".to_string()); + args.push("AskUserQuestion".to_string()); + + if let Some(model) = config.model.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + args.push("--model".to_string()); + args.push(model.to_string()); + } + + args +} + +/// Serialize neutral [`McpServerSpec`]s into claude's `--mcp-config` inline JSON +/// (`{"mcpServers":{"":{…}}}`). `None` when empty so the flag is omitted. +/// Pure `serde_json`, no ACP SDK — `aionui-session` stays SDK-free. +fn build_claude_mcp_config(servers: &[super::McpServerSpec]) -> Option { + use super::McpTransport; + use serde_json::{Map, Value, json}; + if servers.is_empty() { + return None; + } + let kv = |pairs: &[(String, String)]| -> Map { + pairs + .iter() + .map(|(k, v)| (k.clone(), Value::String(v.clone()))) + .collect() + }; + let mut map = Map::new(); + for s in servers { + let entry = match &s.transport { + McpTransport::Stdio { command, args, env } => json!({ + "command": command, + "args": args, + "env": Value::Object(kv(env)), + }), + McpTransport::Http { url, headers } => json!({ + "type": "http", + "url": url, + "headers": Value::Object(kv(headers)), + }), + McpTransport::Sse { url, headers } => json!({ + "type": "sse", + "url": url, + "headers": Value::Object(kv(headers)), + }), + }; + map.insert(s.name.clone(), entry); + } + Some(json!({ "mcpServers": Value::Object(map) }).to_string()) +} + +#[async_trait::async_trait] +impl BackendConnection for ClaudeConnection { + async fn open_session( + &self, + spec: SessionSpec, + config: SessionConfig, + ) -> Result, BackendError> { + let (logical_id, claude_session_id, legacy_spec) = Self::to_legacy_spec(&spec); + let adapter = ClaudeAdapter::new(); + + // Build the init flags claude is spawned with from the session-init surface + // (MCP / preset / model / permission-mode). The clean-slate seam owns this + // (the legacy F1 `ClaudeCodeManager` did it via prelude.rs; that path is not + // on the clean-slate route, so without this the spawned CLI would receive + // NONE of the user's MCP servers / preset context / model / mode). Prepended + // to any caller-supplied `extra_args` so an explicit caller flag still wins + // by position. The SAME args are threaded into the wake recipe so a + // crash/idle-reap respawn re-applies them (R16 continuity). + let init_args = build_claude_init_args(&config); + let spawn_args = prepend_args(&init_args, &config.extra_args); + + // Spawn the persistent process via the legacy adapter (reuses the exact + // flag-building + spawn path, so behavior is verbatim). + let io = adapter + .start_turn( + self.spawner.as_ref(), + &legacy_spec, + config.cwd.as_deref(), + &spawn_args, + &config.spawn_env, + ) + .await + .map_err(|e| BackendError::Transport(format!("claude spawn failed: {e}")))?; + + // F-4 wake recipe: re-spawn on wake by RESUMING the SAME claude session id + // we spawned with (`--session-id ` on Fresh → `--resume + // `), so the on-disk session is re-attached. This id is + // the bare-UUID claude id (NOT the logical demux key), so the resume target + // is always a valid UUID (§4.1). The init flags + spawn env are carried + // verbatim so the re-spawned process gets the same MCP / preset / model / + // mode AND the same provider env (#103). + let wake = ClaudeWakeRecipe { + spawner: self.spawner.clone(), + claude_session_id, + cwd: config.cwd.clone(), + extra_args: spawn_args, + env: config.spawn_env.clone(), + }; + let backend = ClaudeSessionBackend::spawn(logical_id, adapter, io, config, wake).await; + // #98/#101: ask claude for its discovery catalog (selectable models + slash + // commands) up-front via `control_request{initialize}`. The response flows + // back through the reader → `discovered_caps` → `capabilities()`. Best-effort: + // a write failure (e.g. no stdin on a degenerate spawn) is non-fatal — the + // catalog just stays empty (the model/slash pickers degrade, the turn path is + // unaffected). Sent BEFORE any prompt so the catalog is usually present by the + // first `capabilities()` read; a late response is merged on the next read + // (same late-discovery contract as codex `model/list`). + backend.request_initialize().await; + Ok(Arc::new(backend)) + } + + async fn close_session(&self, _session_id: &str) -> Result<(), BackendError> { + // claude is 1:1; dropping the backend handle drops the process (001 + // on-drop hook). Nothing connection-level to release. + Ok(()) + } + + fn capabilities(&self) -> Capabilities { + ClaudeAdapter::new().capabilities() + } +} + +/// Per-session claude handle. `&self`-concurrent: the retained stdin is behind a +/// `Mutex` (a microsecond frame-write lock, NOT a per-turn lock), and `turn_gen` +/// is an atomic the dispatch path bumps + the reader task reads. +pub struct ClaudeSessionBackend { + session_id: String, + capabilities: Capabilities, + /// Retained stdin for prompt/control delivery. `BoxedStdin` taken once from + /// the process; behind a Mutex so concurrent dispatches serialize at the + /// byte-frame level only. `Arc` so a wake (`wake_handle`) can swap in the fresh + /// woken process's stdin (the slot survives suspension; the BoxedStdin inside + /// is replaced). + stdin: Arc>>, + /// The legacy adapter, retained for `deliver_prompt`/`write_control_response` + /// (pure transport framing). Behind a Mutex because those take `&mut stdin`. + adapter: Arc, + /// Live turn epoch (single-writer = dispatch; single-reader = the reader + /// task stamps it onto each envelope). See §5.4. + turn_gen: Arc, + /// Broadcast of wrapped events; `events()` resubscribes. + event_tx: broadcast::Sender, + /// F-4 self-suspend controller: owns the live `{reader, io}` pair and the + /// Active⇄Dormant slot. When `idle_ttl` is set, the idle timer closes the + /// process after inactivity and `dispatch` re-spawns (`--resume`) it via + /// `wake()`. When None (default), the slot stays Active for life — the reader + /// behaves exactly as before F-4 (aborted on Drop via `abort_on_drop`). + suspend: Arc, + /// The per-backend idle timer (Some only when `idle_ttl` is set). Aborted on Drop. + idle_timer: Option>, + /// Everything needed to re-spawn (`--resume`) the claude process on wake from + /// Dormant: the injected spawner, the resume spec, and the cwd/args. Resume + /// keys on the SAME claude session id, so the FSM sees a continuous session. + wake: ClaudeWakeRecipe, + /// Shared reader-task inputs, cloned into the open-time reader AND every + /// post-wake reader so they all drain into the same event_tx/turn_gen. + reader_state: ClaudeReaderState, + /// F-4 turn-active flag (shared with the reader via `reader_state`): set on + /// dispatch(Send), cleared by the reader at the terminal. The idle timer reads + /// it so a streaming turn is never suspended mid-flight. + turn_in_flight: Arc, + /// Pending permission registry keyed by `request_id` (the control correlation + /// key). The reader populates it from each raw `can_use_tool` control_request + /// (storing the tool_use_id + tool_name + input that claude requires echoed in + /// the response); `dispatch(AnswerPermission)` consumes it to build the keyed + /// `control_response`. This is the 007-seam analogue of F1's `ControlChannel` + /// (adapter-private side-channel — it does NOT change the backend-agnostic + /// `SessionEvent::Permission`, which deliberately carries only `request_id`). + /// Shared `Arc` between the reader and dispatch (short synchronous use). + pending_perms: Arc>>, + /// B-CLAUDE-INIT: the current model captured from the `system/init` frame's + /// `model` field (the authoritative current model claude broadcasts at spawn). + /// The reader fills it (sniffing the raw init frame, NOT via parse_chunk → keeps + /// the zero-diff parse contract); `capabilities()` merges it into + /// `current_model` when config did not already supply one. None until init + /// arrives / when config wins. + discovered_model: Arc>>, + /// #98/#101: the selectable model list + slash commands captured from the + /// `control_request{initialize}` RESPONSE (`response.models[]` / + /// `response.commands[]`). Unlike `discovered_model` (the `system/init` DATA + /// frame's single current model), this is the full CATALOG — claude's only + /// channel for it (the bare `--print` data frames never carry a model list; the + /// SDK/ACP `supportedModels()` just forwards this same control response). The + /// reader sniffs the control_response and fills this; `capabilities()` merges it + /// into `available_models`/`slash_commands` on read. Empty until the response + /// lands (a freshly-opened backend reads empty, like codex pre-`model/list`). + discovered_caps: Arc>, + /// G2 (in-band config switch): control_requests (`set_model` / + /// `set_permission_mode`) deferred because they arrived mid-turn. Writing one + /// while a turn is Running would reinitialize the CLI session and TRUNCATE the + /// in-flight turn (raw-CLI limitation), so `dispatch(SetMode/SetModel)` QUEUES + /// the frame here and `dispatch(Send)` drains it — in order, over the same + /// stdin lock, BEFORE the prompt — so a queued switch applies to the NEXT turn + /// and can never land after-and-truncate it. De-duped by subtype (last-write- + /// wins). Mirrors F1's `pending_controls`. + pending_controls: Arc>>, + /// Monotonic counter minting `control_request` request_ids (no uuid dep). The + /// CLI echoes it in its success control_response (observed by the reader, not + /// awaited — the switch applies to the next turn). + control_seq: Arc, + /// CP-1: the last effort level set via `SetConfigOption{effort}`. claude does NOT + /// echo effort back (unlike model/mode), so the backend remembers it here and + /// `capabilities()` surfaces it as `current_effort` for the picker. `None` until + /// the user picks one. A `std::sync::Mutex` (NOT tokio) so the sync `capabilities()` + /// can read it without awaiting — mirrors `discovered_model`/`discovered_caps`. + current_effort: Arc>>, + /// The last permission mode set via `SetMode` (control_request{set_permission_mode}). + /// `capabilities()` surfaces it as `current_mode` so the picker highlights the + /// active mode after a switch (init seeds `current_mode` from config; this carries + /// the RUNTIME override). Mirrors `current_effort`; `None` until the user switches. + current_mode_override: Arc>>, + /// #99 reject-surfacing: carries the `ctl-N` request_id of an in-flight + /// `set_config_option(effort)` (→ a label like `"effort→high"`) so the reader can + /// surface a REJECTION (claude returns `control_response{subtype:"error"}` for a + /// bad effort value) as a `Notice{Warning}` instead of silently dropping it (no + /// handler matched it before — `sniff_mode_reject` hard-filters on "permission + /// mode"). SUCCESS is silent: claude does not echo effort, and + /// `capabilities().current_effort` already tracks it optimistically. A + /// `std::sync::Mutex` (NOT tokio) so the sync reader `process_batch` closure can + /// lock it without awaiting — mirrors `current_mode_override`. + pending_set_config: Arc>>, +} + +/// One outstanding claude `can_use_tool` request, stored so `AnswerPermission` can +/// build the keyed `control_response` (claude blocks the tool until it arrives). +#[derive(Clone)] +struct PendingPerm { + /// The assistant tool_use block id — echoed back as `toolUseID` (required). + tool_use_id: String, + /// Tool name; `AskUserQuestion` (the only interactive tool on claude headless) + /// needs the chosen answer injected into `updatedInput.answers`. + tool_name: String, + /// The original tool input (for AskUserQuestion: `{questions:[…]}`). + input: serde_json::Value, +} + +/// Everything `ClaudeSessionBackend::wake_handle` needs to re-spawn (`--resume`) +/// the claude process after an idle suspend. Resume keys on the SAME bare-UUID +/// claude session id claude was started with (`--session-id ` +/// on Fresh → `--resume `), so the on-disk session is +/// re-attached and the FSM sees a continuous session (§4.1). This is the claude +/// on-disk id (a valid UUID), DISTINCT from the logical demux key. For a +/// test-built backend (`build_with_io`, no real spawner) suspension is never +/// enabled, so it is never consulted. +struct ClaudeWakeRecipe { + spawner: Arc, + claude_session_id: String, + cwd: Option, + extra_args: Vec, + /// #103: the spawn env captured at open time (e.g. cc-switch provider env) so + /// a resume-respawn re-applies the SAME env (R16 continuity — a woken process + /// must reach the same provider as the original). + env: Vec, +} + +/// #98/#101: the discovery catalog captured from the `control_request{initialize}` +/// response — the selectable model list + slash commands claude advertises (the +/// `system/init` data frame carries neither; this control response is the source the +/// SDK/ACP `supportedModels()`/`supportedCommands()` forward). Filled by the reader +/// on the control_response, merged by `capabilities()` on read. Default empty. +#[derive(Clone, Default)] +struct DiscoveredCaps { + models: Vec, + slash_commands: Vec, +} + +/// Shared state the reader task drains into — held by the backend, cloned into +/// each reader (the live one + every post-wake one). Grouped so `spawn` and +/// `wake_handle` start identical readers without a 7-arg call duplicated twice. +#[derive(Clone)] +struct ClaudeReaderState { + session_id: String, + turn_gen: Arc, + event_tx: broadcast::Sender, + pending_perms: Arc>>, + discovered_model: Arc>>, + /// #98/#101: shared catalog the reader fills from the initialize control_response. + discovered_caps: Arc>, + want_init_model: bool, + /// F-4 turn-active flag: set true on dispatch(Send), cleared by the reader at a + /// turn terminal (TurnResult / Detached). The idle timer reads it so a streaming + /// turn is never suspended mid-flight (see SuspendController::suspend_if_idle). + turn_in_flight: Arc, + /// The OBSERVED permission mode (mirror of `ClaudeSessionBackend.current_mode_override`, + /// shared Arc). The reader reconciles it to claude's authoritative + /// `set_permission_mode` control_response — success echoes the applied mode + /// (normal→default), error (e.g. a root-rejected bypass) clears the optimistic + /// value. This is claude's observed-mode track, the analogue of codex's + /// `thread/settings/updated` and ACP's `session/update` reconcile. + current_mode_override: Arc>>, + /// #99: shared map of in-flight `set_config_option(effort)` ctl-ids → label, so + /// `sniff_set_config_reject` can surface a rejection as a `Notice{Warning}` + /// (shared Arc with `ClaudeSessionBackend.pending_set_config`). + pending_set_config: Arc>>, +} + +/// Spawn a claude stdout reader over `stdout`/`io` using the shared state. Used +/// both at open (`spawn`) and on every idle-wake (`wake_handle`), so the reader +/// wiring lives in exactly one place. +fn start_claude_reader( + state: &ClaudeReaderState, + stdout: Option, + io: Arc, +) -> tokio::task::JoinHandle<()> { + let state = state.clone(); + tokio::spawn(async move { + reader_task( + state.session_id, + stdout, + io, + state.turn_gen, + state.event_tx, + state.pending_perms, + state.discovered_model, + state.discovered_caps, + state.want_init_model, + state.turn_in_flight, + state.current_mode_override, + state.pending_set_config, + ) + .await; + }) +} + +impl ClaudeSessionBackend { + /// `take_stdio()` is ONE-SHOT and returns BOTH halves, so we take it exactly + /// once here: stdin is retained behind a Mutex for delivery, stdout is moved + /// into the long-lived reader task. (A failed take → an immediate terminal + /// Detached so the FSM never hangs.) + async fn spawn( + session_id: String, + adapter: ClaudeAdapter, + io: Box, + config: SessionConfig, + wake: ClaudeWakeRecipe, + ) -> Self { + let capabilities = { + let mut caps = adapter.capabilities(); + caps.current_model = config.model.clone(); + caps.current_mode = config.mode.clone(); + caps + }; + let adapter = Arc::new(adapter); + let io: Arc = Arc::from(io); + let turn_gen = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let pending_perms = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); + let discovered_model = Arc::new(std::sync::Mutex::new(None)); + let discovered_caps = Arc::new(std::sync::Mutex::new(DiscoveredCaps::default())); + let turn_in_flight = Arc::new(std::sync::atomic::AtomicBool::new(false)); + // Shared with the reader so it can reconcile the OBSERVED mode to claude's + // `set_permission_mode` control_response (the observed-mode track). + let current_mode_override = Arc::new(std::sync::Mutex::new(None)); + // #99: shared with the reader so a rejected set_config_option(effort) surfaces + // a Notice instead of being silently dropped. + let pending_set_config = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); + // B-CLAUDE-INIT: only let the wire fill current_model when config did NOT + // supply one (config is authoritative; the init frame is the fallback). + let want_init_model = config.model.is_none(); + let (event_tx, _) = broadcast::channel(1024); + + let stdio = io.take_stdio().await; + let (stdin, stdout) = match stdio { + Some((stdin, stdout)) => (Some(stdin), Some(stdout)), + None => (None, None), + }; + + let reader_state = ClaudeReaderState { + session_id: session_id.clone(), + turn_gen: turn_gen.clone(), + event_tx: event_tx.clone(), + pending_perms: pending_perms.clone(), + discovered_model: discovered_model.clone(), + discovered_caps: discovered_caps.clone(), + want_init_model, + turn_in_flight: turn_in_flight.clone(), + current_mode_override: current_mode_override.clone(), + pending_set_config: pending_set_config.clone(), + }; + let reader = start_claude_reader(&reader_state, stdout, io.clone()); + + // F-4: own the live {reader, io} in the SuspendController. idle_ttl=None + // (the default) → no idle timer, slot stays Active for life (production + // parity). idle_ttl=Some → spawn the per-backend idle timer, which never + // suspends while a turn is in flight (turn_in_flight gate). + let suspend = Arc::new(SuspendController::active( + ProcHandle::new(reader, io), + config.idle_ttl_ms, + aionui_common::now_ms(), + )); + let idle_timer = { + let tif = turn_in_flight.clone(); + // 009 R6 cleanup path 3: on an idle-reap suspend, emit BackendSuspended + // so the orchestrator clears this session's workflow_roster (the process + // is gone — a running workflow will never deliver its task_notification). + let etx = event_tx.clone(); + let sid = session_id.clone(); + let tgen = turn_gen.clone(); + spawn_idle_timer( + &suspend, + idle_check_interval_ms(config.idle_ttl_ms), + aionui_common::now_ms, + move || tif.load(std::sync::atomic::Ordering::SeqCst), + move || { + let _ = etx.send(SessionEnvelope { + session_id: sid.clone(), + turn_gen: tgen.load(std::sync::atomic::Ordering::SeqCst), + event: SessionEvent::BackendSuspended, + }); + }, + ) + }; + + Self { + session_id, + capabilities, + stdin: Arc::new(Mutex::new(stdin)), + adapter, + turn_gen, + event_tx, + suspend, + idle_timer, + wake, + reader_state, + turn_in_flight, + pending_perms, + discovered_model, + discovered_caps, + pending_controls: Arc::new(Mutex::new(Vec::new())), + control_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)), + current_effort: Arc::new(std::sync::Mutex::new(None)), + current_mode_override, + pending_set_config, + } + } + + /// Wake from Dormant: re-spawn claude with `--resume `, + /// re-take its stdio, swap the fresh stdin into the retained slot, and start a + /// new reader on the SAME `event_tx`/`turn_gen` — so subscribers and the FSM + /// never notice the process was recycled. Returns the new `{reader, io}` for + /// the controller's slot. Only reached when `idle_ttl` is set AND the slot was + /// suspended (a test backend has no spawner → never enabled). + async fn wake_handle(&self) -> Result { + let legacy_spec = LegacySessionSpec::Resume(self.wake.claude_session_id.clone()); + let io = self + .adapter + .start_turn( + self.wake.spawner.as_ref(), + &legacy_spec, + self.wake.cwd.as_deref(), + &self.wake.extra_args, + &self.wake.env, + ) + .await + .map_err(|e| BackendError::Transport(format!("claude resume-spawn failed: {e}")))?; + let io: Arc = Arc::from(io); + let (stdin, stdout) = match io.take_stdio().await { + Some((stdin, stdout)) => (Some(stdin), Some(stdout)), + None => (None, None), + }; + // Swap the fresh stdin into the retained slot so the next `deliver_prompt` + // writes to the woken process (the old stdin dropped with the old io). + *self.stdin.lock().await = stdin; + let reader = start_claude_reader(&self.reader_state, stdout, io.clone()); + Ok(ProcHandle::new(reader, io)) + } + + /// Wire a user permission answer to claude's blocking `can_use_tool` request + /// (MAJOR-1). Looks up the pending request by `request_id`, builds the keyed + /// `control_response` claude requires (echoing toolUseID; for AskUserQuestion + /// injecting the answer into `updatedInput.answers`), writes it over the + /// retained stdin, and broadcasts `PermissionResolved{request_id}` so the FSM + /// leaves the requires-action sub-state. Mirrors F1's `answer_permission`. + async fn answer_permission( + &self, + request_id: &str, + decision: super::types::PermissionDecision, + selected: Option<&str>, + answers: &[super::types::QuestionAnswer], + ) -> Result { + use std::sync::atomic::Ordering; + let pending = self + .pending_perms + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(request_id); + let Some(pending) = pending else { + return Err(BackendError::Transport(format!( + "no pending permission for request_id {request_id}" + ))); + }; + let response = build_control_response(request_id, &pending, decision, selected, answers); + { + let mut guard = self.stdin.lock().await; + let stdin = guard + .as_mut() + .ok_or_else(|| BackendError::Transport("claude stdin unavailable".into()))?; + self.adapter + .write_control_response(stdin, &response) + .await + .map_err(|e| BackendError::Transport(format!("write control_response: {e}")))?; + } + // RA -1: the reducer leaves requires-action only on PermissionResolved. + let cur_gen = self.turn_gen.load(Ordering::SeqCst); + let _ = self.event_tx.send(SessionEnvelope { + session_id: self.session_id.clone(), + turn_gen: cur_gen, + event: SessionEvent::PermissionResolved { + request_id: request_id.to_string(), + kind: crate::event::PermissionKind::Tool, + }, + }); + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: cur_gen, + }) + } + + /// G2: send a host→CLI `control_request` (set_model / set_permission_mode) over + /// the retained stdin, OR queue it if a turn is in flight. The `turn_in_flight` + /// flag is the backend's in-band proxy for "Running" (set on Send, cleared by + /// the reader at the terminal): a switch written mid-turn reinitializes the CLI + /// session and truncates the in-flight turn, so we defer it to the next prompt + /// drain. De-duped by subtype (last-write-wins) so repeated switches of the + /// same kind collapse. Mirrors F1's `write_control_request`. + /// Returns the minted `ctl-N` request_id so a caller that needs reader-side + /// reconcile (e.g. `SetConfigOption(effort)` → reject-surfacing) can register it. + /// Callers that don't care discard it with `let _ =`. The id is returned whether + /// the frame was written immediately or queued (claude echoes it verbatim in the + /// control_response either way, after the queue drains). + /// Whether `value` is an effort level the CURRENT model advertises + /// (`supportedEffortLevels` → `reasoning_efforts`). The ACP `is_*_valid` semantic + /// ported to effort: an EMPTY / not-yet-discovered catalog is permissive (the + /// initialize control_response has not landed, or the model advertises no efforts — + /// we cannot invalidate against an absent catalog). Only a NON-empty catalog that + /// omits `value` returns false. Resolves the current model from the discovery + /// catalog (matching `capabilities()` current_model precedence: config snapshot + /// first, then the system/init discovered model), falling back to the sole model or + /// the union of all advertised efforts when the current model is ambiguous. + fn effort_is_supported(&self, value: &str) -> bool { + let discovered = self.discovered_caps.lock().unwrap_or_else(|e| e.into_inner()); + if discovered.models.is_empty() { + // Catalog not yet discovered → cannot validate → permissive. + return true; + } + // Resolve the current model id the same way `capabilities()` does. + let current = self + .capabilities + .current_model + .clone() + .or_else(|| self.discovered_model.lock().unwrap_or_else(|e| e.into_inner()).clone()); + // Efforts of the current model if we can pin it; otherwise the union across all + // advertised models (don't reject a level some selectable model supports when + // the current model is unknown). + let efforts: Vec<&str> = match current + .as_deref() + .and_then(|id| discovered.models.iter().find(|m| m.id == id)) + { + Some(model) => model.reasoning_efforts.iter().map(String::as_str).collect(), + None => discovered + .models + .iter() + .flat_map(|m| m.reasoning_efforts.iter().map(String::as_str)) + .collect(), + }; + // A model (or the union) with no advertised efforts → permissive (absent + // catalog can't invalidate, same as ACP empty-catalog semantics). + efforts.is_empty() || efforts.contains(&value) + } + + async fn write_or_queue_control(&self, request: serde_json::Value) -> Result { + use std::sync::atomic::Ordering; + let request_id = format!("ctl-{}", self.control_seq.fetch_add(1, Ordering::SeqCst) + 1); + let frame = serde_json::json!({ + "type": "control_request", + "request_id": request_id, + "request": request, + }); + if self.turn_in_flight.load(Ordering::SeqCst) { + let subtype = control_subtype(&frame); + let mut q = self.pending_controls.lock().await; + q.retain(|f| control_subtype(f) != subtype); + q.push(frame); + return Ok(request_id); + } + self.write_control_frame(&frame).await?; + Ok(request_id) + } + + /// G-A: interrupt the in-flight turn — write `control_request{subtype:"interrupt"}` + /// over the retained stdin IMMEDIATELY (NOT queued: unlike set_model, an interrupt + /// is only meaningful while a turn is running, which is exactly when cancel fires). + /// SDK parity with `query.interrupt()`; probe-verified (claude 2.1.168) to end the + /// turn ~immediately without killing the persistent process. Best-effort — a + /// stdin-closed error means the process already exited (the turn ends on teardown), + /// so we log at debug and let the cancel succeed (the FSM has already unlocked). + async fn interrupt_turn(&self) { + use std::sync::atomic::Ordering; + let request_id = format!("ctl-{}", self.control_seq.fetch_add(1, Ordering::SeqCst) + 1); + let frame = serde_json::json!({ + "type": "control_request", + "request_id": request_id, + "request": { "subtype": "interrupt" }, + }); + if let Err(e) = self.write_control_frame(&frame).await { + tracing::debug!( + session_id = %self.session_id, + error = %e, + "claude interrupt not written (stdin closed?); the turn ends on teardown" + ); + } + } + + /// Drain any queued in-band control_requests over the stdin lock — IN ORDER, + /// BEFORE the prompt — so a switch queued mid-turn applies to THIS next turn and + /// can never land after-and-truncate it. Called at the head of `dispatch(Send)`. + async fn drain_pending_controls(&self) -> Result<(), BackendError> { + let drained: Vec = { + let mut q = self.pending_controls.lock().await; + if q.is_empty() { + return Ok(()); + } + std::mem::take(&mut *q) + }; + for frame in drained { + self.write_control_frame(&frame).await?; + } + Ok(()) + } + + /// #98/#101: send `control_request{initialize}` so claude replies with its + /// discovery catalog (`response.models[]` + `commands[]`). The reader sniffs the + /// success control_response into `discovered_caps`; `capabilities()` merges it. + /// Best-effort (no turn in flight at open, so it writes immediately, not queued); + /// a write error is swallowed — an empty catalog degrades the pickers, never the + /// turn path. + async fn request_initialize(&self) { + use std::sync::atomic::Ordering; + let request_id = format!("ctl-{}", self.control_seq.fetch_add(1, Ordering::SeqCst) + 1); + let frame = serde_json::json!({ + "type": "control_request", + "request_id": request_id, + "request": { "subtype": "initialize" }, + }); + if let Err(e) = self.write_control_frame(&frame).await { + tracing::debug!(error = %e, "claude initialize control_request not sent (catalog stays empty)"); + } + } + + /// Frame + flush one control_request over the retained stdin (same NDJSON path + /// as a control_response). The CLI's success control_response is observed by the + /// reader, not awaited here (the switch applies to the next turn). + async fn write_control_frame(&self, frame: &serde_json::Value) -> Result<(), BackendError> { + let mut guard = self.stdin.lock().await; + let stdin = guard + .as_mut() + .ok_or_else(|| BackendError::Transport("claude stdin unavailable".into()))?; + self.adapter + .write_control_response(stdin, frame) + .await + .map_err(|e| BackendError::Transport(format!("write control_request: {e}"))) + } +} + +/// The `request.subtype` of a control_request frame (set_model / set_permission_mode), +/// used to de-dup the pending-controls queue (last-write-wins per kind). +fn control_subtype(frame: &serde_json::Value) -> Option { + frame + .get("request") + .and_then(|r| r.get("subtype")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) +} + +/// Build the keyed claude `control_response` for a permission decision. Echoes +/// `request_id` INSIDE `response` (claude's correlation key) + `toolUseID`. For +/// AskUserQuestion an allow injects the chosen answer(s) into +/// `updatedInput.answers` (claude silently drops any unanswered question — NOT a +/// re-ask — so an under-answer is silent data loss). The coarse `PermissionDecision` +/// maps Approved/AllowAlways→allow, Denied→deny. +/// +/// AskUserQuestion answers (task #83, live-captured 2.1.178): +/// - `answers` (the FULL per-question set) wins when non-empty: every question +/// claude asked is keyed by its TEXT, the value is the chosen label (single) or a +/// JSON array of labels (multiSelect — claude's zod preprocess joins it with ", "). +/// - else degrade to the single-question path: the explicit `selected` label, else +/// the first option (a plain allow with no specific pick). Keeps single-question / +/// single-select working unchanged. +/// +/// Mirrors F1 control.rs:build_permission_result. +fn build_control_response( + request_id: &str, + pending: &PendingPerm, + decision: super::types::PermissionDecision, + selected: Option<&str>, + answers: &[super::types::QuestionAnswer], +) -> serde_json::Value { + use super::types::PermissionDecision; + use serde_json::json; + let allow = matches!(decision, PermissionDecision::Approved | PermissionDecision::AllowAlways); + let result = if !allow { + json!({ "behavior": "deny", "message": "User rejected the request.", "toolUseID": pending.tool_use_id }) + } else if pending.tool_name == "AskUserQuestion" { + let answers_map = build_ask_user_question_answers(&pending.input, selected, answers); + let mut updated = pending.input.clone(); + if let serde_json::Value::Object(map) = &mut updated { + map.insert("answers".to_string(), answers_map); + } else { + updated = json!({ "answers": answers_map }); + } + json!({ "behavior": "allow", "updatedInput": updated, "toolUseID": pending.tool_use_id }) + } else { + // claude's stdio control-response schema REQUIRES `updatedInput` (a record) on + // the allow branch (unlike the SDK's in-process canUseTool schema where it is + // .optional()). Omitting it makes claude's ZodError reject the whole union + // (`updatedInput: expected record, received undefined`) → "Tool permission + // request failed" → the approved tool never runs (Write/Bash etc. all fail). + // Echo the original tool input unchanged ("run with this input"); fall back to + // an empty object if (defensively) it is not a record so the frame stays valid. + let updated_input = if pending.input.is_object() { + pending.input.clone() + } else { + json!({}) + }; + json!({ "behavior": "allow", "updatedInput": updated_input, "toolUseID": pending.tool_use_id }) + }; + json!({ + "type": "control_response", + "response": { "subtype": "success", "request_id": request_id, "response": result } + }) +} + +/// Build the `updatedInput.answers` object claude's AskUserQuestion reads (live +/// wire 2.1.178, keyed by question TEXT; multi-select value = JSON array of labels +/// which claude joins with ", "). Two sources, in order: +/// 1. `answers` (full per-question set) — used verbatim when non-empty. A single +/// label serializes as a string, multiple as an array. +/// 2. degrade to the single-question path when `answers` is empty: answer ONLY +/// the first question with `selected` (else its first option). This preserves +/// the prior single-question / single-select behavior (plain allow, ACP, etc.). +fn build_ask_user_question_answers( + input: &serde_json::Value, + selected: Option<&str>, + answers: &[super::types::QuestionAnswer], +) -> serde_json::Value { + use serde_json::json; + if !answers.is_empty() { + let map: serde_json::Map = answers + .iter() + .map(|a| { + // One label → bare string; many → array (claude accepts either and + // joins arrays with ", "). An empty `labels` degrades to "". + let value = match a.labels.as_slice() { + [] => json!(""), + [one] => json!(one), + many => json!(many), + }; + (a.question.clone(), value) + }) + .collect(); + return serde_json::Value::Object(map); + } + // Degrade: single-question path keyed by the FIRST question's text. + let q0 = input + .get("questions") + .and_then(serde_json::Value::as_array) + .and_then(|qs| qs.first()); + let question = q0 + .and_then(|q| q.get("question").and_then(serde_json::Value::as_str)) + .unwrap_or(""); + let label = selected + .map(str::to_string) + .or_else(|| { + q0.and_then(|q| q.get("options").and_then(serde_json::Value::as_array)) + .and_then(|opts| opts.first()) + .and_then(|o| o.get("label").and_then(serde_json::Value::as_str)) + .map(str::to_string) + }) + .unwrap_or_default(); + json!({ question: label }) +} + +impl Drop for ClaudeSessionBackend { + /// Parity with codex M5: abort the live reader so its `Arc` clone + /// is released and a mid-turn-dropped / hung claude process is reaped + /// (kill_on_drop) instead of leaking. `abort_on_drop` reaches the controller's + /// mirrored AbortHandle without awaiting the async slot (Drop cannot await). + /// Also stop the per-backend idle timer if one was running. + fn drop(&mut self) { + self.suspend.abort_on_drop(); + if let Some(timer) = &self.idle_timer { + timer.abort(); + } + } +} + +/// The idle-check cadence for a given ttl: poll at ~ttl/4 (bounded 1s..=30s) so a +/// suspend fires within a quarter-ttl of going idle without a busy loop. Only +/// consulted when `idle_ttl` is Some (else no timer is spawned). +fn idle_check_interval_ms(idle_ttl_ms: Option) -> u64 { + match idle_ttl_ms { + Some(ttl) => ((ttl / 4).clamp(1_000, 30_000)) as u64, + None => 30_000, + } +} + +/// DIAGNOSTIC (env-gated, default OFF): when `AIONUI_CLAUDE_WIRE_DUMP` is set, log +/// the RAW stdin/stdout bytes claude exchanges. This is the only way to settle +/// "send accepted but no output frames" — it shows whether the CLI returned ANY +/// bytes after a prompt (CLI hang) vs returned bytes the parser dropped. OFF by +/// default because it logs full prompt/output content (the AGENTS.md sensitive-payload +/// rule forbids that in normal production); it is a deliberate debugging switch a +/// developer turns on to reproduce, never enabled by default. +fn claude_wire_dump_enabled() -> bool { + std::env::var("AIONUI_CLAUDE_WIRE_DUMP").is_ok_and(|v| v != "0" && !v.is_empty()) +} + +/// Emit one raw-bytes wire line (direction + conv + turn_gen + byte count + a +/// lossy-UTF8 preview, truncated). Only called when the dump gate is on. +fn dump_wire(direction: &str, session_id: &str, turn_gen: u64, bytes: &[u8]) { + const MAX: usize = 4096; + let preview = String::from_utf8_lossy(&bytes[..bytes.len().min(MAX)]); + tracing::info!( + target: "aionui_session::claude_wire", + direction, + conversation_id = %session_id, + turn_gen, + byte_len = bytes.len(), + truncated = bytes.len() > MAX, + preview = %preview, + "claude wire bytes" + ); +} + +/// The long-lived stdout reader: drain → parse → wrap (stamp live turn_gen) → +/// broadcast. Owns its own `ClaudeAdapter` parse buffer (persists across turns, +/// the persistent process's stdout does not EOF between turns). On EOF/exit it +/// surfaces `Detached{exit}` so the FSM resolves (no `wait_for_exit` on the seam). +#[allow(clippy::too_many_arguments)] +async fn reader_task( + session_id: String, + stdout: Option, + io: Arc, + turn_gen: Arc, + event_tx: broadcast::Sender, + pending_perms: Arc>>, + discovered_model: Arc>>, + discovered_caps: Arc>, + want_init_model: bool, + turn_in_flight: Arc, + current_mode_override: Arc>>, + pending_set_config: Arc>>, +) { + use std::sync::atomic::Ordering; + use tokio::io::AsyncReadExt; + + let Some(mut stdout) = stdout else { + // stdio could not be taken → emit a terminal Detached so nothing hangs. + // Startup double-take guard: no stderr to attribute → G2 summary None. + turn_in_flight.store(false, Ordering::SeqCst); + let cur_gen = turn_gen.load(Ordering::SeqCst); + let _ = event_tx.send(SessionEnvelope { + session_id, + turn_gen: cur_gen, + event: SessionEvent::Detached { + exit: None, + redacted_summary: None, + }, + }); + return; + }; + + let mut parser = ClaudeAdapter::new(); + let mut chunk = [0u8; 4096]; + // Startup-only zero-frame liveness check (resume-hang); armed below at the read + // loop. `seen_frame` is process-level + ONE-SHOT — see the loop comment for the + // full rationale and the deliberate "single turns are never timed" scope. + let mut seen_frame = false; + // Process one batch of parsed frames (from `frame_lines` OR `flush_tail`): + // sniff the raw frame for permission/init/subagent side-channels, then + // broadcast each canonical event. Shared so the EOF tail-flush (009 R1a) + // runs the IDENTICAL processing as the live loop — a truncated final frame + // must not be handled any differently than a `\n`-terminated one. + // Bug-A fix (claude-only, proactive=true): the epoch of the wire turn currently + // OPEN, locked at its `system/init` (claude's authoritative turn-open boundary, + // §3.5). A `TurnResult` is stamped with THIS, not the read-time `turn_gen`, so a + // trailing result from a turn that was cancelled/superseded by a proactive resend + // keeps its OWN (older) turn's epoch and the reducer's cross-turn guard + // (result_epoch < since_epoch) drops it. Read-time stamping mis-attributed it: the + // resend's eager turn_gen bump lands BEFORE the late result is read (probe + // `_all_zerogap_cancel.jsonl` C: same-ms), so the cancelled turn's `is_error` + // result was stamped the NEW turn's epoch → not dropped → spurious Error bubble. + // `init`↔`result` is 1:1 and ordered (§3.5), and the late result is always read + // BEFORE the next turn's init, so turn_open_epoch is still the old turn's value + // when it arrives. 0 = no turn opened yet → fall back to read-time (no regression + // for the first frames). See protocols/design/claude-midturn-input-turn-gen-design.md §4-A. + let mut turn_open_epoch: u64 = 0; + let mut process_batch = |batch: Vec<(Option, Vec)>| { + let cur_gen = turn_gen.load(Ordering::SeqCst); + for (raw, events) in batch { + if let Some(v) = &raw { + // Lock the open-turn epoch at the authoritative turn-open boundary + // (system/init). Every subsequent result of THIS turn is stamped with + // it until the next turn's init re-locks it (bug-A, see above). + if v.get("type").and_then(serde_json::Value::as_str) == Some("system") + && v.get("subtype").and_then(serde_json::Value::as_str) == Some("init") + { + turn_open_epoch = cur_gen; + } + register_or_clear_pending(v, &pending_perms); + // B-CLAUDE-INIT: sniff the system/init frame for the current + // model + MCP server statuses (the init broadcast the legacy + // parse_system drops). Done on the RAW frame so parse_chunk's + // event stream stays zero-diff. Emits Provisioning per MCP + // server (parity with codex mcpServerStatus→Provisioning). + sniff_init(v, want_init_model, &discovered_model, &event_tx, &session_id, cur_gen); + // #98/#101: sniff the `control_request{initialize}` RESPONSE for the + // selectable model list + slash commands (claude's only catalog + // channel — the data init frame above carries neither). Fills + // discovered_caps; capabilities() merges it on read. Done on the RAW + // frame (parse_chunk drops control frames to opaque). + sniff_control_initialize(v, &discovered_caps, &event_tx, &session_id, cur_gen); + // AUTHORITATIVE mode signal (design §9.10.1 option A / README #10): + // claude stamps `permissionMode` on system/init AND system/status. This + // single inbound path confirms EVERY mode change — user-driven (a + // set_permission_mode also yields a system/status) AND autonomous (claude + // exits plan mode on its own → emits ONLY system/status). It replaces the + // old optimistic dispatch emit (de-optimistic'd). normal→default normalized; + // dedups so a repeated init/status echo of the same mode is silent. + sniff_mode(v, ¤t_mode_override, &event_tx, &session_id, cur_gen); + // The ONE case system/status can't cover: a REJECTED set_permission_mode + // (claude refused → no status, only a control_response error). Clears the + // stale override + surfaces mode_switch_rejected. + sniff_mode_reject(v, ¤t_mode_override, &event_tx, &session_id, cur_gen); + // #99: the analogue for set_config_option(effort). An effort REJECTION + // (claude returns control_response{error} for a bad effort value) matched + // no handler before and was SILENTLY DROPPED. Routed by the ctl-id we + // minted + registered in pending_set_config → surface a Notice{Warning}. + // SUCCESS is silent (claude does not echo effort); the entry is just removed. + sniff_set_config_reject(v, &pending_set_config, &event_tx, &session_id, cur_gen); + // NO set_model reader-side reconcile (design §9.10.1, Optimistic tier). + // LIVE-PROBED (2.1.187, protocols/samples/claude-cli/2.1.187/_all_set_model.jsonl): + // claude's set_model control_response is a BARE {subtype:"success"} — no + // model echoed, a bogus id ALSO returns success — AND an in-band set_model + // emits NO fresh system/init (two set_model sends → zero subsequent init). + // So there is NO inbound signal at all to confirm/reconcile the switch (the + // official Agent SDK treats set_model as fire-and-forget for the same reason). + // dispatch(SetModel) emits ConfigChanged{model} optimistically and that is + // final in-band; a bad id surfaces only when the next turn USES it (API 404). + // (Do NOT add a reconcile keyed on a fresh system/init — it never arrives + // in-band; a prior comment wrongly claimed "read back from the next turn's + // system/init", disproved.) set_permission_mode is different: its ack DOES + // echo response.mode (sniff_set_mode_response / sniff_mode real). + // + // PARTIAL correction (gap-reaudit): the binary 2.1.191 set_model handler + // HAS a synchronous error branch for ids that fail an allowlist — but + // `Na` returns true for ANY id when NO model allowlist is configured, so + // on our Bedrock path (no allowlist) even a bogus id returns success + // (matches the 2.1.187 probe). A real reject control_response{subtype:error} + // therefore ONLY fires in allowlist/restricted-model orgs — a shape we have + // NOT live-probed. Per the "no parser for an unprobed shape" discipline we do + // NOT speculatively wire a sniff_set_model_reject; FOLLOW-UP gated on capturing + // that reject frame in a restricted-model environment, then route it by the + // `ctl-N` request_id we mint (not by guessing the error string). + // QuerySessionInfo reply: claude answers our in-band + // `control_request{get_context_usage|get_session_cost}` with a success + // control_response keyed by our `ctl-qsi-{usage|cost}-N` request_id. + // Sniff it → SessionEvent::SessionInfo (the cumulative context-budget / + // cost snapshot the user asked for). Done on the RAW frame. + sniff_session_info(v, &event_tx, &session_id, cur_gen); + // Subagent roster: claude emits system/task_* frames for + // Task/Workflow subagents (§6b b1). Translate them to + // SubagentUpdate so the reducer upserts Running.subagents — + // which drives has_activity (a subagent still running keeps + // the spinner on even while the main turn blocks on approval). + // Done on the RAW frame (parse_chunk drops task_* to opaque). + sniff_task(v, &event_tx, &session_id, cur_gen); + // B (regression A): claude's NATIVE prompt-ack. A replayed user frame + // (--replay-user-messages) carrying OUR stamped `uuid` means claude has + // truly consumed that message into THIS turn → emit PromptAccepted so + // the conversation drains the matching pending head Sent→Accepted. Only + // a frame whose uuid is a non-empty string we could have stamped fires; + // claude-MINTED user frames (tool_result, the [Request interrupted] + // ghost) carry claude's own uuid and simply won't match any outstanding + // client_msg_id downstream (drain_pending_on is a precise single-id + // match → no-op), so this stays safe even though we can't tell them + // apart at the wire. Done on the RAW frame. + sniff_replay_prompt_ack(v, &event_tx, &session_id, cur_gen); + } + for ev in events { + // F-4: a terminal clears the turn-active flag so the idle + // timer may suspend the now-idle process (it was held + // resident for the whole turn). Cleared BEFORE the broadcast + // so the flag is already false when subscribers react. + if matches!(ev, SessionEvent::TurnResult { .. }) { + turn_in_flight.store(false, Ordering::SeqCst); + } + // Bug-A: a TurnResult is stamped with the OPEN turn's locked epoch + // (set at this turn's system/init), NOT the read-time turn_gen — so a + // late result from a turn superseded by a proactive resend keeps its + // own (older) epoch and the reducer's cross-turn guard drops it. All + // OTHER events keep the read-time epoch (they belong to the live turn + // and carry no cross-turn staleness). turn_open_epoch==0 (no init yet) + // falls back to cur_gen (first-frames / no regression). The + // orchestrator's restamp_epoch then propagates this into + // TurnResult.epoch (it copies env.turn_gen when the adapter left 0). + let env_gen = if matches!(ev, SessionEvent::TurnResult { .. }) && turn_open_epoch != 0 { + turn_open_epoch + } else { + cur_gen + }; + let _ = event_tx.send(SessionEnvelope { + session_id: session_id.clone(), + turn_gen: env_gen, + event: ev, + }); + } + } + }; + // Startup-only zero-frame liveness (resume-hang). A claude `--resume ` whose + // on-disk session is a broken/empty husk hangs the spawned process (0% CPU, + // sleeping) — it emits NO stream-json frame and never EOFs, so this read would + // park forever, the turn never terminates, and the UI locks permanently. The + // existing crash self-heal can't help (it keys on an Error terminal, which a + // hung non-exiting process never produces). + // + // The guard is deliberately STARTUP-ONLY: we bound the read by `handshake_budget` + // ONLY until the process has produced its very first frame; once it proves it is + // alive (any frame: system/init, replay, anything), `seen_frame` latches true and + // the read goes UNBOUNDED for the rest of the process's life. A long single turn + // is NEVER timed — by owner decision (a turn that thinks/tools for minutes is + // normal and must not be killed). This catches the real wedge (a spawn/resume + // that hangs before emitting anything) without risking a healthy in-progress turn. + // + // On the hung verdict we surface a terminal Detached{exit:None} (→ reducer + // Error{Crashed} → UI unlocks; the husk is reaped by the next get_or_build + // eviction's kill_on_drop). We do NOT call wait_for_exit on a hang — a live + // process would block it forever. `read` is cancel-safe so the bounded read + // loses no bytes if the timer elapses. + let mut hung = false; + // Set when the parse+broadcast path panicked (see the catch_unwind in the read + // loop). Like `hung`, it routes to a terminal Detached without waiting on the + // process (which is still alive) so the turn ends as a crash instead of hanging. + let mut panicked = false; + // Windows pipe-EOF gap (F48-adjacent): claude's stdout write handle can be + // inherited by a surviving grandchild (a detached MCP/tool descendant). When the + // user kills the claude leaf, the pipe's write end is NOT fully closed while such + // a descendant lives, so `stdout.read()` NEVER returns 0 — the reader would park + // forever, `Detached` would never fire, and the UI would wedge at `pending` with + // no error. (macOS has close-on-exec on the fd, so EOF is prompt there and this + // race never wins — but the guard is unconditional: it is correct on every OS and + // simply never fires when EOF/error already terminate first.) So we cannot rely on + // EOF alone; we race the unbounded read against the process's exit watch + // (`io.wait_for_exit()`, backed by a cancel-safe `watch::Receiver` over the direct + // child's `child.wait()` — orthogonal to the stdout pipe). When the exit leg wins, + // `proc_exited` carries the status so the terminal `Detached` reuses it instead of + // re-awaiting `wait_for_exit` (which would race a second borrow / re-resolve). + let mut proc_exited: Option> = None; + loop { + // DIAGNOSTIC: mark each read-loop iteration entry. If the log shows this line + // but then NO matching stdout/eof/error outcome for a long time, the reader is + // blocked inside `stdout.read().await` waiting for bytes claude never sends + // (the suspected resume stall) — vs the loop not running at all. + if claude_wire_dump_enabled() { + tracing::debug!( + target: "aionui_session::claude_wire", + direction = "read", + conversation_id = %session_id, + outcome = "awaiting", + seen_frame, + "claude stdout read: awaiting bytes" + ); + } + let read = if seen_frame { + // Proven alive → unbounded read (a long turn is never timed), BUT raced + // against the process's exit watch so a Windows pipe-EOF stall (a surviving + // grandchild holding the write end → no `Ok(0)` ever) still terminates the + // turn. Both `select!` legs are cancel-safe: `stdout.read` is; `wait_for_exit` + // is a `watch::Receiver::changed()` (loses nothing when the read leg wins). + tokio::select! { + biased; + // Prefer the read: while bytes are flowing we must drain them (a turn + // that also just exited still has its `result` frame to deliver). The + // exit leg only wins once the read is genuinely parked with no bytes. + r = stdout.read(&mut chunk) => r.map_err(|_| ()), + exit = io.wait_for_exit() => { + // The direct child exited but stdout has not EOF'd (the Windows + // inherited-handle case). Do NOT tear down yet: the pipe buffer may + // still hold the final `result` frame. Bounded-drain it (EOF may + // never come, so we cannot wait for `Ok(0)`), then break to the + // existing terminal path with the captured exit status. + if claude_wire_dump_enabled() { + tracing::info!( + target: "aionui_session::claude_wire", + direction = "read", + conversation_id = %session_id, + outcome = "process_exited", + "claude process exited while stdout still open (no EOF); bounded-draining tail" + ); + } + loop { + match tokio::time::timeout(std::time::Duration::from_millis(200), stdout.read(&mut chunk)).await { + // More buffered bytes: process them exactly as the live loop + // would (same panic net → `panicked` short-circuits the drain). + Ok(Ok(n)) if n > 0 => { + if claude_wire_dump_enabled() { + dump_wire("stdout", &session_id, turn_gen.load(Ordering::SeqCst), &chunk[..n]); + } + let parsed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + parser.frame_lines(&chunk[..n]) + })); + match parsed { + Ok(batch) => process_batch(batch), + Err(_) => { + tracing::error!( + target: "aionui_session::backend::claude_conn", + conversation_id = %session_id, + "claude frame parser panicked during post-exit drain; ending turn as crash" + ); + panicked = true; + break; + } + } + } + // Drain complete (EOF, error, or the 200ms budget elapsed + // with no more bytes) → stop draining. + _ => break, + } + } + // Remember the captured status so the terminal path below reuses it + // (do NOT re-await wait_for_exit). `Some(None)` = exited, status + // unknown (WaitErrored) — still a real terminal, distinct from `hung`. + proc_exited = Some(exit); + break; + } + } + } else { + // Startup window: bound the FIRST frame by the handshake budget. + match tokio::time::timeout(super::handshake_budget(), stdout.read(&mut chunk)).await { + Ok(r) => r.map_err(|_| ()), + Err(_) => { + // Budget elapsed before the process emitted ANY frame → wedged + // startup (e.g. a broken --resume). Terminal Detached unsticks it. + // DIAGNOSTIC: this is the silent "startup read timed out" path — + // distinguishes "claude produced NO bytes at all" from a parse issue. + if claude_wire_dump_enabled() { + tracing::info!( + target: "aionui_session::claude_wire", + direction = "read", + conversation_id = %session_id, + outcome = "startup_timeout", + "claude stdout read: startup budget elapsed with zero frames" + ); + } + hung = true; + break; + } + } + }; + match read { + Ok(0) => { + // DIAGNOSTIC: EOF — claude closed stdout (process winding down). Logged + // because a silent EOF mid-conversation (vs claude staying alive but + // quiet) is a completely different root cause. + if claude_wire_dump_enabled() { + tracing::info!( + target: "aionui_session::claude_wire", + direction = "read", + conversation_id = %session_id, + outcome = "eof", + "claude stdout read: EOF (stdout closed)" + ); + } + break; // EOF: process winding down + } + Ok(n) => { + // DIAGNOSTIC: raw stdout bytes BEFORE parsing — shows exactly what the + // CLI returned (incl. frames the parser would drop to opaque). + if claude_wire_dump_enabled() { + dump_wire("stdout", &session_id, turn_gen.load(Ordering::SeqCst), &chunk[..n]); + } + seen_frame = true; // proven alive — disarm the startup guard for life + // `frame_lines` gives BOTH the raw frame Value AND the parsed + // events from ONE parse (no double-parse). + // + // Panic-safety net (class defence, NOT a root-cause substitute): + // a panic anywhere in the parse+broadcast path (e.g. a byte-index + // String op that splits a UTF-8 char, an unchecked index on wire + // data) would otherwise unwind THIS task silently — dropping stdout + // WITHOUT emitting a terminal, so the pump blocks forever and the + // conversation is wedged at `pending`. Catching it here downgrades + // ANY future parser panic to "this turn crashed" (terminal Detached + // below → reducer Error{Crashed} → UI unlocks) instead of a permanent + // hang. `AssertUnwindSafe` is sound: on a caught panic we STOP reading + // and tear down, so no partially-mutated parser/state is reused. + let parsed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parser.frame_lines(&chunk[..n]))); + match parsed { + Ok(batch) => process_batch(batch), + Err(_) => { + // error level: a parser panic is a contract violation we must be + // able to diagnose in production. No payload (no frame bytes / + // prompt) — only the fact + location context via the panic hook. + tracing::error!( + target: "aionui_session::backend::claude_conn", + conversation_id = %session_id, + "claude frame parser panicked; ending turn as crash (see panic hook for location)" + ); + panicked = true; + break; // → terminal Detached path + } + } + } + Err(()) => { + // DIAGNOSTIC: stdout read errored (pipe broken / process gone) — a + // distinct terminal cause from a clean EOF or a quiet-but-alive process. + if claude_wire_dump_enabled() { + tracing::info!( + target: "aionui_session::claude_wire", + direction = "read", + conversation_id = %session_id, + outcome = "error", + "claude stdout read: I/O error (pipe broken)" + ); + } + break; // read error → terminal + } + } + } + + // 009 R1a: drain-before-honor a truncated final frame. If the process died + // mid-write (OOM/SIGKILL during the `result` line), the trailing half-line + // is still in the parser buffer; flush it as a final frame BEFORE the + // terminal Detached so its content/result is not silently lost (and the turn + // is not misclassified as empty). A clean EOF on a `\n` boundary flushes + // nothing. Skipped on a parse panic: the parser buffer holds the very bytes + // that just panicked, so re-parsing them via flush_tail would panic AGAIN + // (this time uncaught) — go straight to the terminal. + if !panicked { + process_batch(parser.flush_tail()); + } + + // EOF/exit is terminal too → clear the turn flag (the process is gone). + turn_in_flight.store(false, Ordering::SeqCst); + // A zero-frame hang OR a parse panic leaves the process ALIVE — `wait_for_exit` + // would block forever, so skip it and report `exit: None` (the reducer maps a + // None-exit Detached to Error{Crashed}, same as an unknown-status exit). The husk + // process is reaped by the next get_or_build eviction (kill_on_drop). If the exit + // watch ALREADY won the read race (`proc_exited`), reuse that captured status — do + // NOT re-await `wait_for_exit` (the process is gone; re-awaiting is redundant and + // the status is in hand). Otherwise (clean EOF / read error) wait for and redact + // the exit as before. `peek_stderr` is still safe on either path (it reads the + // buffered tail, never blocks on the process). + let exit = if hung || panicked { + None + } else if let Some(captured) = proc_exited { + captured + } else { + io.wait_for_exit().await + }; + // G2: redact the stderr tail at the backend boundary so a crash carries a + // user-facing reason (allowlisted, ≤240 chars) without leaking raw stderr. + let redacted_summary = crate::adapter::redact_exit_stderr(io.as_ref()).await; + let cur_gen = turn_gen.load(Ordering::SeqCst); + let _ = event_tx.send(SessionEnvelope { + session_id, + turn_gen: cur_gen, + event: SessionEvent::Detached { exit, redacted_summary }, + }); +} + +/// Sniff a raw claude frame: register a `can_use_tool` control_request into the +/// pending-permission map (so `AnswerPermission` can build the keyed response), +/// and clear it on a `control_cancel_request` (claude retracted it). Mirrors F1's +/// `ControlChannel::register`/`cancel`. No-op for any other frame. +fn register_or_clear_pending( + frame: &serde_json::Value, + pending: &Arc>>, +) { + use serde_json::Value; + match frame.get("type").and_then(Value::as_str) { + Some("control_request") => { + let request = frame.get("request"); + if request.and_then(|r| r.get("subtype")).and_then(Value::as_str) != Some("can_use_tool") { + return; + } + let Some(request_id) = frame.get("request_id").and_then(Value::as_str) else { + return; + }; + let request = request.unwrap(); + let tool_use_id = request.get("tool_use_id").and_then(Value::as_str).unwrap_or(""); + if tool_use_id.is_empty() { + return; // can't echo toolUseID → can't answer; parse degrades it to opaque too + } + pending.lock().unwrap_or_else(|e| e.into_inner()).insert( + request_id.to_string(), + PendingPerm { + tool_use_id: tool_use_id.to_string(), + tool_name: request + .get("tool_name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + input: request.get("input").cloned().unwrap_or(Value::Null), + }, + ); + } + Some("control_cancel_request") => { + // claude retracted the request → drop the pending (it can no longer be + // answered; the b-side PermissionResolved already clears the FSM count). + if let Some(request_id) = frame.get("request_id").and_then(Value::as_str) { + pending.lock().unwrap_or_else(|e| e.into_inner()).remove(request_id); + } + } + _ => {} + } +} + +/// B-CLAUDE-INIT: sniff a raw `system/init` frame for discovery data the legacy +/// `parse_system` drops. Captures `model` into `discovered_model` (only when +/// `want_init_model`, i.e. config supplied none) and emits a `Provisioning` event +/// per `mcp_servers[]` entry (connected→ToolsReady, failed→LoadFailed, +/// needs-auth→Degraded) — parity with codex `mcpServerStatus→Provisioning`, so a +/// failed/needs-auth MCP server is visible on the claude seam too. No-op for any +/// non-init frame. Done on the raw frame (NOT parse_chunk) to keep zero-diff. +fn sniff_init( + frame: &serde_json::Value, + want_init_model: bool, + discovered_model: &Arc>>, + event_tx: &broadcast::Sender, + session_id: &str, + turn_gen: u64, +) { + use serde_json::Value; + if frame.get("type").and_then(Value::as_str) != Some("system") + || frame.get("subtype").and_then(Value::as_str) != Some("init") + { + return; + } + if want_init_model && let Some(model) = frame.get("model").and_then(Value::as_str) { + *discovered_model.lock().unwrap_or_else(|e| e.into_inner()) = Some(model.to_string()); + } + // Addendum 9 parity (codex thread/started, acp session/new|load): lower the + // authoritative on-disk session id from the init frame as BackendBound, so the + // conversation persists it as the resume anchor. Emit ONLY when it differs from + // the logical id we spawned with (a no-rotation session stays silent — the + // common case where claude was started with `--session-id `); a + // DIFFERENT id means claude rotated/resumed under another on-disk id, which is + // the value a later `--resume` must target. + if let Some(sid) = frame.get("session_id").and_then(Value::as_str) + && sid != session_id + { + let _ = event_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen, + event: SessionEvent::BackendBound { + backend_session_id: Some(sid.to_string()), + }, + }); + } + if let Some(servers) = frame.get("mcp_servers").and_then(Value::as_array) { + for s in servers { + let name = s.get("name").and_then(Value::as_str).unwrap_or(""); + let phase = match s.get("status").and_then(Value::as_str).unwrap_or("") { + "connected" => crate::event::ProvisioningPhase::ToolsReady, + "failed" => crate::event::ProvisioningPhase::LoadFailed { + reason: format!("mcp server '{name}' failed"), + }, + "needs-auth" | "needs_auth" => crate::event::ProvisioningPhase::Degraded { + reason: format!("mcp server '{name}' needs auth"), + }, + // pending/unknown → still provisioning + _ => crate::event::ProvisioningPhase::ToolsWaiting, + }; + let _ = event_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen, + event: SessionEvent::Provisioning { phase }, + }); + } + } +} + +/// Sniff the AUTHORITATIVE mode signal: claude stamps `permissionMode` on BOTH its +/// `system/init` (turn/session start) AND `system/status` (any mode change) frames. +/// This is the UNIFIED inbound mode-truth — it fires for a user-driven set +/// (`set_permission_mode` also produces a system/status) AND for an AUTONOMOUS change +/// (claude exits plan mode on its own after an approved ExitPlanMode → emits ONLY a +/// system/status, no control_response). LIVE-PROBED 2.1.187 +/// (protocols/samples/claude-cli/2.1.187/_all_autonomous_mode.jsonl: set plan → +/// system/status{plan}; autonomous exit → system/status{bypassPermissions}). +/// +/// Design §9.10.1 option A (de-optimistic): mode is confirmed by THIS inbound signal, +/// NOT by an optimistic dispatch emit — so dispatch(SetMode) no longer emits +/// ConfigChanged; this is the single path, covering active + autonomous with no gap. +/// (Contracts README discipline #10: never sense only our-own-triggered changes — +/// claude's autonomous plan-exit was dropped because no sniffer read system/status.) +/// +/// `normal` → `default` normalization (claude's internal name for our `default`, +/// matching `sniff_set_mode_response`). Adopts the value as the authoritative +/// `current_mode_override` (the picker re-read surface) + emits `ConfigChanged{mode}`. +/// No-op for any non-system frame or a system frame without `permissionMode`. +fn sniff_mode( + frame: &serde_json::Value, + current_mode_override: &Arc>>, + event_tx: &broadcast::Sender, + session_id: &str, + turn_gen: u64, +) { + use serde_json::Value; + if frame.get("type").and_then(Value::as_str) != Some("system") { + return; + } + let Some(raw) = frame.get("permissionMode").and_then(Value::as_str) else { + return; + }; + let mode = if raw == "normal" { "default" } else { raw }; + // Reconcile only on a real change so a repeated init/status echo of the same mode + // does not spam ConfigChanged (it is reducer-ignored, but keep the stream clean). + { + let mut cur = current_mode_override.lock().unwrap_or_else(|e| e.into_inner()); + if cur.as_deref() == Some(mode) { + return; + } + *cur = Some(mode.to_string()); + } + let _ = event_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen, + event: SessionEvent::ConfigChanged { + mode: Some(mode.to_string()), + model: None, + }, + }); +} + +/// #98/#101: sniff a raw `control_response{subtype:"success"}` for the +/// `initialize` reply's discovery catalog and fill `discovered_caps`. The +/// `response` object carries `models[{value, displayName, description, +/// supportsEffort, supportedEffortLevels[]}]` and `commands[{name, description}]` +/// — the selectable model list + slash commands claude advertises (live-probed +/// 2.1.181; fixture protocols/samples/claude-cli/2.1.181/control_initialize_response). +/// This is claude's ONLY catalog channel: the `system/init` DATA frame carries +/// only the current model, and the SDK/ACP `supportedModels()` just forwards this +/// same control response. +/// +/// No request_id correlation: `initialize` is the only control_request we send that +/// yields a `models`/`commands`-bearing success response, so a success frame with +/// those keys is unambiguously the initialize reply. No-op for any other frame +/// (can_use_tool success, set_model ack, etc. carry no `models`). Done on the RAW +/// frame (parse_chunk drops control frames to opaque) — keeps the parse zero-diff. +fn sniff_control_initialize( + frame: &serde_json::Value, + discovered_caps: &Arc>, + event_tx: &broadcast::Sender, + session_id: &str, + turn_gen: u64, +) { + use crate::capability::{ModelInfo, SlashCommandInfo}; + use serde_json::Value; + if frame.get("type").and_then(Value::as_str) != Some("control_response") { + return; + } + let Some(response) = frame.get("response") else { + return; + }; + if response.get("subtype").and_then(Value::as_str) != Some("success") { + return; + } + // The success payload nests the actual init response under `response`. + let Some(inner) = response.get("response") else { + return; + }; + // Only the initialize reply carries `models`; skip any other success response. + let models = inner.get("models").and_then(Value::as_array); + let commands = inner.get("commands").and_then(Value::as_array); + if models.is_none() && commands.is_none() { + return; + } + let parsed_models: Vec = models + .map(|models| { + models + .iter() + .filter_map(|m| { + let id = m.get("value").and_then(Value::as_str)?.to_string(); + let mut reasoning_efforts: Vec = m + .get("supportedEffortLevels") + .and_then(Value::as_array) + .map(|arr| arr.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + // Surface the synthetic `ultracode` level (xhigh + standing dynamic + // workflow orchestration) — the CLI's own effort-picker entry — but + // only for xhigh-capable models, mirroring the CLI gate. It rides the + // same picker + `effort_is_supported` path as real levels; only the + // dispatch wire differs (see `ULTRACODE_LEVEL`). + if reasoning_efforts.iter().any(|e| e == XHIGH_LEVEL) + && !reasoning_efforts.iter().any(|e| e == ULTRACODE_LEVEL) + { + reasoning_efforts.push(ULTRACODE_LEVEL.to_string()); + } + Some(ModelInfo { + name: m.get("displayName").and_then(Value::as_str).unwrap_or(&id).to_string(), + description: m.get("description").and_then(Value::as_str).map(str::to_string), + reasoning_efforts, + id, + }) + }) + .collect() + }) + .unwrap_or_default(); + let parsed_commands: Vec = commands + .map(|commands| { + commands + .iter() + .filter_map(|c| { + let name = c.get("name").and_then(Value::as_str)?.to_string(); + Some(SlashCommandInfo { + name, + description: c.get("description").and_then(Value::as_str).map(str::to_string), + }) + }) + .collect() + }) + .unwrap_or_default(); + { + let mut caps = discovered_caps.lock().unwrap_or_else(|e| e.into_inner()); + if models.is_some() { + caps.models = parsed_models.clone(); + } + if commands.is_some() { + caps.slash_commands = parsed_commands.clone(); + } + } + // Signal the async catalog arrival so the conversation re-projects the picker + // (the ACP `emit_snapshot_events` analogue). Without this the frontend, which + // read an empty `config_options` on open, never re-fetches and the model + // selector stays disabled. Carry claude's fixed permission modes too: the + // frontend replaces the WHOLE config_options snapshot on this frame, so omitting + // modes would wipe the (synchronously-available) mode picker — a fresh regression. + let _ = event_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen, + event: SessionEvent::CatalogUpdated { + models: parsed_models, + modes: crate::adapter::claude_permission_modes(), + slash_commands: parsed_commands, + }, + }); +} + +/// Handle a REJECTED `set_permission_mode` — the ONE mode signal `sniff_mode` cannot +/// cover. A successful mode change (active or autonomous) is confirmed by the inbound +/// `system/status{permissionMode}` that `sniff_mode` reads (design §9.10.1 option A). +/// But a REJECTED change emits NO system/status (claude refused, so the mode did not +/// change) — it only comes back as a `control_response{subtype:error}`, e.g. a +/// root-rejected bypass ("session was not launched with --dangerously-skip-permissions"). +/// We CLEAR any stale override so `capabilities()` reflects the mode claude actually +/// enforces (no lying picker) + surface `AdapterSpecific{tag:"mode_switch_rejected"}`. +/// +/// (The success arm of this function was REMOVED: with dispatch(SetMode) de-optimistic'd, +/// the success reconcile is owned solely by `sniff_mode` via system/status — the single +/// inbound path covering user-driven AND autonomous changes, README discipline #10.) +/// +/// Self-identifying: a "permission mode" error string distinguishes a mode rejection +/// from other control errors. No-op for any non-error / non-mode frame. +fn sniff_mode_reject( + frame: &serde_json::Value, + current_mode_override: &Arc>>, + event_tx: &broadcast::Sender, + session_id: &str, + turn_gen: u64, +) { + use serde_json::Value; + if frame.get("type").and_then(Value::as_str) != Some("control_response") { + return; + } + let response = frame.get("response").unwrap_or(&Value::Null); + if response.get("subtype").and_then(Value::as_str) != Some("error") { + return; + } + let err = response.get("error").and_then(Value::as_str).unwrap_or(""); + // Only act on a permission-mode rejection (other control errors — e.g. a failed + // set_model — are not ours to reconcile here). + if !err.contains("permission mode") { + return; + } + // The switch did not take → clear any override so capabilities() falls back to the + // mode claude actually enforces (no lying picker). + *current_mode_override.lock().unwrap_or_else(|e| e.into_inner()) = None; + let _ = event_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen, + event: SessionEvent::AdapterSpecific { + tag: "mode_switch_rejected".to_string(), + payload: serde_json::json!({ "error": err }), + }, + }); +} + +/// #99: reconcile a `control_response` for an in-flight `set_config_option(effort)`. +/// claude does NOT echo effort, so a SUCCESS is silent (`capabilities().current_effort` +/// already tracks it optimistically) — we only claim the pending entry. A REJECTION +/// (`control_response{subtype:"error"}` for a bad effort value) matched NO handler +/// before (`sniff_mode_reject` hard-filters on "permission mode") and was silently +/// dropped, so the user never learned the set failed; here we surface it as a +/// `Notice{Warning}` carrying the label + claude's error string. Routed strictly by the +/// `ctl-N` request_id we minted in the SetConfigOption arm, so it never disturbs the +/// permission-mode path (a permission-mode reject has no pending_set_config entry). +fn sniff_set_config_reject( + frame: &serde_json::Value, + pending_set_config: &Arc>>, + event_tx: &broadcast::Sender, + session_id: &str, + turn_gen: u64, +) { + use serde_json::Value; + if frame.get("type").and_then(Value::as_str) != Some("control_response") { + return; + } + let response = frame.get("response").unwrap_or(&Value::Null); + let Some(request_id) = response.get("request_id").and_then(Value::as_str) else { + return; + }; + let is_error = response.get("subtype").and_then(Value::as_str) == Some("error"); + // Claim (remove) the entry only if THIS response is for one of our effort sets. + let Some(label) = pending_set_config + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(request_id) + else { + return; + }; + if !is_error { + // Success is silent — claude does not echo effort; the optimistic + // current_effort already reflects it. Just drop the pending entry (done above). + return; + } + let err = response.get("error").and_then(Value::as_str).unwrap_or("set rejected"); + tracing::error!( + session_id = %session_id, + set = %label, + "claude set_config_option(effort) rejected: {err}" + ); + let _ = event_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen, + event: SessionEvent::Notice { + level: crate::event::NoticeLevel::Warning, + message: format!("{label} failed: {err}"), + }, + }); +} + +// (set_model has NO reader-side reconcile — see the reader-loop note at the +// sniff_set_mode_response call site + design §9.10.1: claude's set_model ack is a +// bare success with no model echo, so the switch is Optimistic, confirmed by the +// next turn's system/init. A parser here would be permanently inert + self-confirming.) + +/// Request-id prefix tagging a `QuerySessionInfo` control_request so the reader can +/// route its success control_response to the right `SessionInfoKind` (claude echoes +/// the request_id verbatim, but the response body for usage vs cost is structurally +/// different, so we disambiguate on the id we minted). +const QSI_USAGE_PREFIX: &str = "ctl-qsi-usage-"; +const QSI_COST_PREFIX: &str = "ctl-qsi-cost-"; + +/// The synthetic reasoning-effort level that mirrors the claude CLI's own interactive +/// effort-picker entry `"ultracode (xhigh + dynamic workflow orchestration; this session +/// only)"`. It is NOT a model-advertised `supportedEffortLevels` value — `fill_discovery` +/// injects it into a model's `reasoning_efforts` (so it surfaces in the picker and passes +/// `effort_is_supported`) ONLY when that model advertises `xhigh`, matching the CLI's gate +/// (`ultracode` requires an xhigh-capable model + dynamic workflows). On dispatch it does +/// NOT ride the `effortLevel` field: it is sent as the dedicated boolean +/// `apply_flag_settings{settings:{ultracode:true}}` — LIVE-PROBED 2.1.206 +/// (samples/claude-cli/2.1.206/ultracode_wire.result.md): the flag returns +/// control_response{success} and `get_settings.applied` reads back `{effort:"xhigh", +/// ultracode:true}`, whereas sending `effortLevel:"ultracode"` would be rejected by our +/// own `effort_is_supported` gate since it is absent from `supportedEffortLevels`. +const ULTRACODE_LEVEL: &str = "ultracode"; +/// The base effort level `ultracode` extends (and which the CLI auto-forces when the flag +/// is set). Used to gate ultracode injection to xhigh-capable models. +const XHIGH_LEVEL: &str = "xhigh"; + +/// Sniff the success control_response to a `QuerySessionInfo` (G): claude answers +/// `control_request{get_context_usage}` with `response.response.{totalTokens, +/// maxTokens, categories[]}` and `{get_session_cost}` with `response.response.text` +/// (live-confirmed 2.1.186, samples/claude-cli/2.1.186). Routed by the +/// `ctl-qsi-{usage|cost}-N` request_id we minted. No-op for any other frame. +/// B (regression A): claude's NATIVE prompt-ack via `--replay-user-messages`. When +/// claude consumes one of OUR user messages into a turn it replays that frame with +/// the `uuid` WE stamped (= the conversation's `client_msg_id`, see +/// `ClaudeAdapter::deliver_prompt`). Emitting `PromptAccepted{client_msg_id: uuid}` +/// here drains the matching pending head Sent→Accepted (the bubble flips +/// sending→sent) only once claude has REALLY taken the message — replacing the old +/// flush-ok synthesized emit that lied for a proactively-queued (or cancel-dropped) +/// message. Probe-pinned echo: protocols/samples/claude-cli/2.1.187/_all_replay_uuid.jsonl. +/// +/// Guard: only a `type:"user"` frame carrying a NON-EMPTY top-level `uuid` fires. +/// claude also replays frames it MINTED itself (tool_result user frames, the +/// `[Request interrupted]` ghost) with claude's OWN uuid; those won't match any +/// outstanding `client_msg_id` (the conversation's `drain_pending_on` is a precise +/// single-id match → no-op), so emitting for them is harmless. We do NOT try to +/// distinguish minted-vs-ours at the wire (the uuid namespace is the only signal, +/// and the downstream precise match is the real gate). A `tool_result`-bearing user +/// frame is skipped defensively (it is never one of our top-level prompts). +fn sniff_replay_prompt_ack( + frame: &serde_json::Value, + event_tx: &broadcast::Sender, + session_id: &str, + turn_gen: u64, +) { + use serde_json::Value; + if frame.get("type").and_then(Value::as_str) != Some("user") { + return; + } + let Some(uuid) = frame.get("uuid").and_then(Value::as_str).filter(|s| !s.is_empty()) else { + return; + }; + // Defensive: a user frame whose content is a tool_result is a claude-minted + // continuation, never one of our top-level prompts — skip it (its uuid is + // claude's and would no-op downstream anyway, but skipping avoids the spurious + // event entirely). + let is_tool_result = frame + .get("message") + .and_then(|m| m.get("content")) + .and_then(Value::as_array) + .map(|blocks| { + blocks + .iter() + .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result")) + }) + .unwrap_or(false); + if is_tool_result { + return; + } + let _ = event_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen, + event: SessionEvent::PromptAccepted { + client_msg_id: uuid.to_string(), + }, + }); +} + +fn sniff_session_info( + frame: &serde_json::Value, + event_tx: &broadcast::Sender, + session_id: &str, + turn_gen: u64, +) { + use serde_json::Value; + if frame.get("type").and_then(Value::as_str) != Some("control_response") { + return; + } + let response = frame.get("response").unwrap_or(&Value::Null); + if response.get("subtype").and_then(Value::as_str) != Some("success") { + return; + } + let request_id = response.get("request_id").and_then(Value::as_str).unwrap_or(""); + let inner = response.get("response").unwrap_or(&Value::Null); + + let event = if request_id.starts_with(QSI_USAGE_PREFIX) { + let used = inner.get("totalTokens").and_then(Value::as_u64).unwrap_or(0); + let max = inner.get("maxTokens").and_then(Value::as_u64).unwrap_or(0); + let categories = inner + .get("categories") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|c| { + let name = c.get("name").and_then(Value::as_str)?.to_string(); + let tokens = c.get("tokens").and_then(Value::as_u64).unwrap_or(0); + Some(crate::event::ContextUsageCategory { name, tokens }) + }) + .collect() + }) + .unwrap_or_default(); + SessionEvent::SessionInfo { + context_usage: Some(crate::event::ContextUsage { used, max, categories }), + cost_text: None, + } + } else if request_id.starts_with(QSI_COST_PREFIX) { + let text = inner.get("text").and_then(Value::as_str).unwrap_or("").to_string(); + SessionEvent::SessionInfo { + context_usage: None, + cost_text: Some(text), + } + } else { + return; // not a QuerySessionInfo reply (initialize / set_mode / can_use_tool ack) + }; + + let _ = event_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen, + event, + }); +} + +/// Translate a raw claude `system/task_*` frame into a `SubagentUpdate` (§6b b1). +/// claude emits these for Task/Workflow subagents; the reducer upserts them into +/// `Running.subagents` (keyed by `r#ref`), which `has_foreground_activity` reads +/// so the spinner stays on while a subagent runs. No-op for any non-task frame. +/// +/// Wire (verified against `tests/fixtures/claude_2.1.169_single_tool_turn.ndjson`): +/// - `task_started` {task_id, tool_use_id, subagent_type?, workflow_name?} → Running +/// - `task_progress` {task_id, ...} → Running (still alive) +/// - `task_notification`{task_id, status: completed|failed|stopped} → terminal +/// +/// `task_id` is the stable lifecycle key (= `r#ref`); `tool_use_id` is the parent +/// ToolCall (= `parent_ref`); `subagent_type`/`workflow_name` is the label. +fn sniff_task( + frame: &serde_json::Value, + event_tx: &broadcast::Sender, + session_id: &str, + turn_gen: u64, +) { + use crate::event::SubagentStatus; + use serde_json::Value; + if frame.get("type").and_then(Value::as_str) != Some("system") { + return; + } + let subtype = frame.get("subtype").and_then(Value::as_str).unwrap_or(""); + let status = match subtype { + "task_started" | "task_progress" | "task_updated" => SubagentStatus::Running, + "task_notification" => match frame.get("status").and_then(Value::as_str) { + Some("failed") => SubagentStatus::Errored, + Some("stopped") => SubagentStatus::Interrupted, + _ => SubagentStatus::Completed, // "completed" or unknown terminal + }, + _ => return, // not a task frame + }; + let Some(task_id) = frame.get("task_id").and_then(Value::as_str) else { + return; // no stable ref → cannot upsert + }; + let label = frame + .get("workflow_name") + .or_else(|| frame.get("subagent_type")) + .and_then(Value::as_str) + .map(str::to_string); + let parent_ref = frame.get("tool_use_id").and_then(Value::as_str).map(str::to_string); + let _ = event_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen, + event: SessionEvent::SubagentUpdate { + r#ref: task_id.to_string(), + label, + status, + parent_ref, + }, + }); + + // 009 R6b / H1: emit RICH per-agent detail from `workflow_progress[]`. The + // workflow (task_id) is 1:N over its per-agent children (workflow_agent + // entries), each carrying display fields the panel renders. Keyed by `agentId` + // (present once running) falling back to `label` (the start frame has only + // index/label); parent_ref = task_id (the container). Each child is a + // SubagentDetail; the orchestrator folds them into workflow_roster. + if let Some(agents) = frame.get("workflow_progress").and_then(Value::as_array) { + for a in agents + .iter() + .filter(|a| a.get("type").and_then(Value::as_str) == Some("workflow_agent")) + { + let label = a.get("label").and_then(Value::as_str); + let Some(agent_ref) = a.get("agentId").and_then(Value::as_str).or(label).map(str::to_string) else { + continue; // no stable ref for this agent + }; + let loop_state = match a.get("state").and_then(Value::as_str) { + Some("start") => Some(crate::state::WorkflowLoopState::Start), + Some("progress") => Some(crate::state::WorkflowLoopState::Progress), + Some("done") => Some(crate::state::WorkflowLoopState::Done), + _ => None, + }; + let _ = event_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen, + event: SessionEvent::SubagentDetail { + r#ref: agent_ref, + parent_ref: Some(task_id.to_string()), + label: label.map(str::to_string), + loop_state, + model: a.get("model").and_then(Value::as_str).map(str::to_string), + tokens: a.get("tokens").and_then(Value::as_u64), + tool_calls: a.get("toolCalls").and_then(Value::as_u64), + last_tool_name: a.get("lastToolName").and_then(Value::as_str).map(str::to_string), + }, + }); + } + } +} + +#[async_trait::async_trait] +impl SessionBackend for ClaudeSessionBackend { + async fn dispatch(&self, command: Command) -> Result { + use std::sync::atomic::Ordering; + match command { + Command::Send { content, metadata } => { + // OBSERVABILITY: dispatch entered (turn driver reached the backend). + // The chain solo-send→facade→dispatch→deliver_prompt→stdin was a black + // hole; these three markers (entered / about-to-write / delivered) pin + // WHERE a no-output turn stalls. Shape only (block count, not text). + tracing::info!( + conversation_id = %self.session_id, + block_count = content.len(), + "claude dispatch(Send): entered" + ); + // §C6 Layer-2: reject any block kind this backend does not + // advertise BEFORE wire-write — never silently drop it + // ("adapter authoritatively rejects → CommandNotSupported, never a silent drop"). claude + // headless `--print` carries text + image (native base64 block) + + // resource (ResourceLink → Read-tool path ref); audio/at_mention + // are rejected, keyed on their `content_block:` name. + let blocks = self.capabilities().prompt_blocks; + if let Some(bad) = content.iter().find(|b| !blocks.allows(b)) { + return Err(BackendError::CommandNotSupported { + command: crate::capability::block_kind_name(bad), + }); + } + // F-4: ensure the process is awake before any wire write. When + // idle_ttl=None (default) the slot is always Active → this is a + // single uncontended lock + atomic store (no wake, no spawn), so + // the dispatch path stays byte-identical to pre-F-4. When the slot + // was idle-suspended, this re-spawns claude with `--resume` first. + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + // G2: drain any queued in-band config switch (set_mode/set_model) + // BEFORE marking the turn in flight + writing the prompt, so a switch + // queued mid-previous-turn applies to THIS turn (and cannot land after + // the prompt and truncate it). Drains over the same stdin lock, in + // order. Done while still "idle" (turn_in_flight not yet set). + self.drain_pending_controls().await?; + // F-4: mark the turn in flight so the idle timer won't suspend the + // process mid-turn (the reader clears it at the terminal). Set after + // a successful wake, before the wire write. + self.turn_in_flight.store(true, Ordering::SeqCst); + { + // first-send-race-500 #2: is this the FIRST send on a process + // that has never accepted one? `turn_gen` is bumped only AFTER a + // successful delivery (below), so `== 0` ⇔ "no prompt has landed + // yet" ⇔ the process may still be completing startup. A + // deliver_prompt failure THERE is the claude analog of codex/acp's + // bound-thread/bound-session handshake miss: the agent is still + // coming up (the home-page warmup/send race hits a just-spawned + // claude before its control plane is ready). Classify it as the + // RETRYABLE HandshakeTimeout (→ session_bridge → BackendUnavailable + // → 502 "agent starting, retry") instead of a bare Transport→500. + // A failure AFTER the first successful send stays Transport (an + // established process that drops a write is genuinely broken — an + // honest terminal, not a startup race). We do NOT retry the write: + // a not-ready-but-alive process buffers stdin (the write would + // succeed), so a write error means the pipe is broken = process + // gone — where a retry is futile AND risks a corrupt frame (a + // partial write_all + a retried full frame). The reader's + // Detached→Error{Crashed}→evict path self-heals a dead process; the + // client's retry then rebuilds Fresh. + let starting = self.turn_gen.load(Ordering::SeqCst) == 0; + let wrap = |e: String| { + if starting { + BackendError::HandshakeTimeout(format!("claude still starting: {e}")) + } else { + BackendError::Transport(format!("deliver_prompt: {e}")) + } + }; + let mut guard = self.stdin.lock().await; + let stdin = guard.as_mut().ok_or_else(|| wrap("stdin unavailable".into()))?; + tracing::info!( + conversation_id = %self.session_id, + first_send = starting, + "claude dispatch(Send): writing prompt to stdin" + ); + self.adapter + .deliver_prompt(stdin, &content, metadata.client_msg_id.as_deref()) + .await + .map_err(|e| wrap(e.to_string()))?; + } // stdin lock released (microsecond frame-write lock, §5.4) + tracing::info!( + conversation_id = %self.session_id, + "claude dispatch(Send): prompt delivered to stdin (awaiting CLI frames)" + ); + // turn_gen++ on accept (§5.4): still bumped here — it drives the + // orchestrator's Idle→Running latch (TurnStarted{epoch: receipt.turn_gen}) + // and the per-turn epoch. PromptAccepted is NO LONGER synthesized here. + // + // Bug-A / B (regression A): claude has a REAL prompt-ack after all — it + // echoes our user-frame `uuid` (= client_msg_id) in the + // `--replay-user-messages` frame ONLY when it actually consumes that + // message into a turn (LIVE-pinned, see protocols/design/ + // claude-midturn-input-turn-gen-design.md §3.3). The reader's + // `sniff_replay_prompt_ack` emits PromptAccepted on that echo. This + // replaces the old flush-ok "Synthesized" emit, which lied for a + // proactively-queued message (flush succeeds the instant we write, but + // claude may sit on it for seconds, or DROP it if the turn is cancelled + // before it is drained — the bubble must not flip to sent until claude + // really took it). This brings claude to codex-parity (Native ack). + let cur_gen = self.turn_gen.fetch_add(1, Ordering::SeqCst) + 1; + Ok(CommandReceipt { + accepted: true, + admission: Admission::Started, + turn_gen: cur_gen, + }) + } + Command::Cancel { target } => { + match target { + CancelTarget::Turn | CancelTarget::Session => { + // G-A: ACTUALLY interrupt the in-flight turn over the retained + // stdin (claude is a LONG-LIVED process; the orchestrator's + // lowered Cancel folds Running→Idle on OUR side, but without + // this write claude keeps running the whole turn in the + // background — wasted tokens, "cancel that didn't cancel"). + // Write `control_request{subtype:"interrupt"}` IMMEDIATELY (not + // queued like set_model — the turn IS in flight, that is the + // point; SDK parity: query.interrupt(), probe-verified 2.1.168 + // ends the turn ~immediately). The trailing late `result` claude + // emits is dropped by the reducer's epoch guard (restamp_epoch + + // result_epoch < since_epoch), so it never lands in a new turn. + // Best-effort: a stdin-closed write error means the process is + // already gone (the turn ends on teardown) — log, do not fail + // the cancel (the FSM already unlocked). + self.interrupt_turn().await; + } + CancelTarget::Tool(_) => { + return Err(BackendError::CommandNotSupported { command: "cancel_tool" }); + } + } + let cur_gen = self.turn_gen.load(Ordering::SeqCst); + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: cur_gen, + }) + } + // cap=false ↔ dispatch-rejects (Layer-2: authoritatively reject, never a + // silent drop). Rewind is NOT WIRED YET — deferred, not impossible: the + // rewind_files protocol DOES exist in 2.1.191 (gap-reaudit correction), but + // it needs a num_turns→user_message_id map + checkpoint infra we don't carry. + // Reachable follow-up when rewind UX is wanted (probe shapes captured). Same + // for ListCheckpoints. + Command::Rewind { .. } => Err(BackendError::CommandNotSupported { command: "rewind" }), + Command::ListCheckpoints => Err(BackendError::CommandNotSupported { + command: "list_checkpoints", + }), + // G: query claude's cumulative session info over the in-band control plane + // (get_context_usage / get_session_cost, live-confirmed 2.1.186). A + // read-only query (Admission::NoTurn): mint a kind-tagged request_id so the + // reader routes the success control_response → SessionEvent::SessionInfo. + // Written immediately (not queued like set_mode): a query does not mutate + // turn state, and we want the answer promptly. + Command::QuerySessionInfo { kind } => { + use std::sync::atomic::Ordering; + let (subtype, prefix) = match kind { + super::types::SessionInfoKind::ContextUsage => ("get_context_usage", QSI_USAGE_PREFIX), + super::types::SessionInfoKind::SessionCost => ("get_session_cost", QSI_COST_PREFIX), + }; + let request_id = format!("{prefix}{}", self.control_seq.fetch_add(1, Ordering::SeqCst) + 1); + let frame = serde_json::json!({ + "type": "control_request", + "request_id": request_id, + "request": { "subtype": subtype }, + }); + self.write_control_frame(&frame).await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::AnswerAuth { .. } => Err(BackendError::CommandNotSupported { command: "answer_auth" }), + Command::Steer { .. } => Err(BackendError::CommandNotSupported { command: "steer" }), + // G2: in-band config switch via control_request (probe-verified, mirrors + // F1). set_permission_mode / set_model are written over the retained + // stdin WITHOUT restarting the process; the switch applies to the NEXT + // turn. Mid-turn writes would reinitialize + TRUNCATE the in-flight turn, + // so they QUEUE (drained before the next prompt). On a successful + // dispatch we emit ConfigChanged so the UI confirms immediately. + Command::SetMode { mode } => { + // DE-OPTIMISTIC (design §9.10.1 option A / README #10): we write the + // set_permission_mode request and STOP — no optimistic ConfigChanged, no + // optimistic override write. The confirmation comes from claude's inbound + // `system/status{permissionMode}` (sniff_mode), which fires for BOTH this + // user-driven switch AND an autonomous one (plan-exit). Routing both + // through the single inbound signal means the UI never shows a mode claude + // hasn't applied (no reverse drift), and the autonomous case is covered by + // construction. A rejected switch comes back as a control_response error + // (sniff_mode_reject). The picker re-read surface (`current_mode_override`) + // is set by sniff_mode on the confirming status, not here. + let _ = self + .write_or_queue_control(serde_json::json!({ "subtype": "set_permission_mode", "mode": mode })) + .await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::SetModel { model } => { + // PURELY OPTIMISTIC by wire constraint (design §9.10.1). set_model is + // in-band (no respawn), and LIVE-PROBED (2.1.187) claude gives it NO + // confirmation channel whatsoever: + // - the set_model control_response is a bare {subtype:"success"} with no + // model echo (a bogus id also returns success) — no confirm/reject; + // - it does NOT emit a fresh `system/init` (init fires only on spawn/ + // resume, NOT on an in-band set) — verified: `_all_set_model.jsonl` shows + // two set_model sends with ZERO subsequent system/init. + // So there is NO inbound signal to reconcile the applied model against — + // unlike set_permission_mode (which echoes via control_response + system/ + // status). The official Agent SDK treats set_model as fire-and-forget for + // the same reason. We emit ConfigChanged{model} OPTIMISTICALLY (UI selector + // updates at once) and STOP. Do NOT add a reconcile path keyed on a fresh + // system/init — that frame never arrives in-band (a prior comment wrongly + // claimed "reconciled from the next turn's system/init"; disproved). A bad + // model id surfaces only when the NEXT turn actually tries to use it (API + // 404). There is deliberately NO reader-side set_model response parser + // (it would be permanently inert + self-confirming — README discipline #9). + let _ = self + .write_or_queue_control(serde_json::json!({ "subtype": "set_model", "model": model.clone() })) + .await?; + let cur_gen = self.turn_gen.load(Ordering::SeqCst); + let _ = self.event_tx.send(SessionEnvelope { + session_id: self.session_id.clone(), + turn_gen: cur_gen, + event: SessionEvent::ConfigChanged { + mode: None, + model: Some(model), + }, + }); + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: cur_gen, + }) + } + // #99: generic config option. EFFORT is the only one worth exposing on + // current models (`supportedEffortLevels` per model, from initialize). The + // binary (2.1.191) ALSO has a `set_max_thinking_tokens` control arm, but + // budget_tokens thinking is deprecated on Opus/Sonnet 4.6+ in favor of + // adaptive-thinking + effort, so we don't surface it (gap-reaudit: the prior + // "only EFFORT exists" claim was wire-inaccurate; "only one worth exposing" + // is the accurate framing). Effort is set via + // `control_request{apply_flag_settings, settings:{effortLevel}}` — + // LIVE-PROBED (2.1.181): shallow-merge, immediate, no restart (NOT + // `set_effort`, which is Unsupported). Queued behind an in-flight turn + // like set_mode/set_model. No ConfigChanged emit: that event carries only + // mode/model (no effort field); the frontend confirms effort by re-reading + // (get_settings). Any other option_id rejects (cap=false ↔ reject). + Command::SetConfigOption { option_id, value } => match option_id.as_str() { + "effort" | "reasoning_effort" | "thought_level" => { + // Validate against the current model's advertised effort catalog + // (`supportedEffortLevels` → `reasoning_efforts`) BEFORE sending — + // the ACP `clear_invalid_desired_*` semantic ported to effort. An + // unsupported level (e.g. a stale picker "max" against a model that + // only offers low/medium/high) would be rejected by claude next turn + // AND poison the optimistic `current_effort` we store below. Empty / + // unknown catalog → permissive (matches ACP `is_*_valid`: absent + // catalog can't invalidate). REJECT (not silent-drop): the caller + // asked for a level the model can't honor. + if !self.effort_is_supported(&value) { + return Err(BackendError::Transport(format!( + "effort level '{value}' is not supported by the current model" + ))); + } + // `ultracode` is not an `effortLevel` value; it is the dedicated + // boolean flag `settings.ultracode` (which the CLI auto-forces to + // xhigh). Every other level rides `effortLevel`. LIVE-PROBED 2.1.206 + // (samples/claude-cli/2.1.206/ultracode_wire.result.md). + let settings = if value == ULTRACODE_LEVEL { + serde_json::json!({ "ultracode": true }) + } else { + serde_json::json!({ "effortLevel": value }) + }; + let request_id = self + .write_or_queue_control(serde_json::json!({ + "subtype": "apply_flag_settings", + "settings": settings, + })) + .await?; + // #99: register the minted ctl-id so the reader surfaces a REJECTION + // (bad effort value → control_response{error}) as a Notice instead of + // silently dropping it. Success is silent (claude does not echo effort); + // the reader just removes the entry on a matching success. + self.pending_set_config + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(request_id, format!("effort\u{2192}{value}")); + // CP-1: claude does not echo effort back, so remember it here → + // `capabilities().current_effort` highlights the active level for + // the picker (the frontend confirms by re-reading get_config_options). + *self.current_effort.lock().unwrap_or_else(|e| e.into_inner()) = Some(value.clone()); + let cur_gen = self.turn_gen.load(Ordering::SeqCst); + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: cur_gen, + }) + } + _ => Err(BackendError::CommandNotSupported { + command: "set_config_option", + }), + }, + // AnswerPermission: wire the control_response (the F3 permission answer). + Command::AnswerPermission { + request_id, + decision, + selected, + answers, + } => { + self.answer_permission(&request_id, decision, selected.as_deref(), &answers) + .await + } + // Acknowledge: a conversation-side fold (done-unseen → seen). NO claude + // wire; accept as a local no-op (§C1). + Command::Acknowledge { .. } => { + let cur_gen = self.turn_gen.load(Ordering::SeqCst); + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: cur_gen, + }) + } + } + } + + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + let rx = self.event_tx.subscribe(); + // Hand-roll a stream over the broadcast receiver via `unfold` (avoids a + // tokio-stream dep). A `Lagged` recv error skips the gap and continues + // (the orchestrator's own broadcast layer surfaces backpressure as U21); + // a `Closed` error ends the stream. + futures_util::stream::unfold(rx, |mut rx| async move { + loop { + match rx.recv().await { + Ok(env) => return Some((env, rx)), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + }) + .boxed() + } + + fn capabilities(&self) -> Capabilities { + // B-CLAUDE-INIT: merge the init-discovered current_model when config did not + // supply one (the snapshot's current_model is None in that case; the reader + // fills discovered_model from the system/init frame). Read-only sync lock. + let mut caps = self.capabilities.clone(); + if caps.current_model.is_none() + && let Some(model) = self.discovered_model.lock().unwrap_or_else(|e| e.into_inner()).clone() + { + caps.current_model = Some(model); + } + // #98/#101: merge the initialize-response discovery catalog (selectable + // models + slash commands). Empty until the control_response lands — a fresh + // read sees [] (like codex pre-`model/list`); the conversation re-reads. + let discovered = self.discovered_caps.lock().unwrap_or_else(|e| e.into_inner()); + if !discovered.models.is_empty() { + caps.available_models = discovered.models.clone(); + } + if !discovered.slash_commands.is_empty() { + caps.slash_commands = discovered.slash_commands.clone(); + } + // CP-1: surface the last-set effort (claude does not echo it back). + if let Some(effort) = self.current_effort.lock().unwrap_or_else(|e| e.into_inner()).clone() { + caps.current_effort = Some(effort); + } + // Surface the last RUNTIME mode switch (init seeded current_mode from config; + // a SetMode override supersedes it for the picker highlight). + if let Some(mode) = self + .current_mode_override + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + { + caps.current_mode = Some(mode); + } + caps + } + + /// REST-recovery (`GET /confirmations`) source: the adapter's transient + /// pending-permission registry IS the set of currently-unanswered permissions + /// (insert on each `can_use_tool` control_request, remove on `AnswerPermission` + /// and on `control_cancel_request`). Map each entry to a safe view — `request_id` + /// (the card's id/call_id) + `tool_name` (the title). The raw tool `input` is NOT + /// exposed (TIO-13: it carries command bodies / args). claude does not advertise + /// options, so the recovered card's options default is synthesized frontend-side. + fn pending_permission_requests(&self) -> Vec { + self.pending_perms + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .map(|(request_id, perm)| { + // AskUserQuestion recovery: surface input.questions so the REST + // /confirmations path rebuilds a question card (symmetric to the live + // ConfirmationAdded projection in turn_finalizer). Only AskUserQuestion + // carries `input`, so questions stays None for ordinary tools. + let questions = if perm.tool_name == "AskUserQuestion" { + perm.input.get("questions").cloned() + } else { + None + }; + PendingPermissionView { + request_id: request_id.clone(), + tool_name: perm.tool_name.clone(), + questions, + } + }) + .collect() + } +} + +// Re-export the session_id accessor for tests / orchestration. +impl ClaudeSessionBackend { + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// #99 test-support seam: pre-register a pending `set_config_option(effort)` + /// ctl-id + label so a hermetic fixture can replay an error control_response and + /// assert the reader surfaces a `Notice{Warning}` (not a silent drop). On the live + /// path `dispatch(SetConfigOption{effort})` registers it. + #[cfg(any(test, feature = "test-support"))] + pub fn set_pending_set_config_for_test(&self, request_id: impl Into, label: impl Into) { + self.pending_set_config + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(request_id.into(), label.into()); + } + + /// Test-support seam (§C5 verification): build a backend over an injected + /// `AgentIo` (a `FakeAgentIo` replaying fixtures) WITHOUT spawning a real + /// process — proving the dispatch/reader/events wiring end-to-end. Gated so + /// production never ships it. + #[cfg(any(test, feature = "test-support"))] + pub async fn build_with_io(session_id: impl Into, io: Box) -> Self { + let session_id = session_id.into(); + // A test backend never suspends (config.idle_ttl_ms = None), so the wake + // recipe is never consulted — but `spawn` needs one. Use a FakeSpawner. + let wake = ClaudeWakeRecipe { + spawner: Arc::new(crate::testing::FakeSpawner::new()), + claude_session_id: session_id.clone(), + cwd: None, + extra_args: Vec::new(), + env: Vec::new(), + }; + Self::spawn(session_id, ClaudeAdapter::new(), io, SessionConfig::default(), wake).await + } + + /// Test-support seam: build a SUSPENDABLE backend over an injected `AgentIo`, + /// with a caller-supplied `Spawner` (to observe the wake re-spawn) and an + /// `idle_ttl_ms`. Lets a test drive the suspend→wake path hermetically: the + /// idle timer suspends the idle slot, and the next dispatch wakes via the + /// supplied spawner (asserting the `--resume ` recipe). + #[cfg(any(test, feature = "test-support"))] + pub async fn build_with_io_suspending( + session_id: impl Into, + io: Box, + spawner: Arc, + idle_ttl_ms: i64, + ) -> Self { + let session_id = session_id.into(); + // Test backends drive the wake path directly over the supplied spawner; the + // resume id is the test's session id verbatim (the assertion checks + // `--resume `), so it is NOT routed through claude_session_id_for. + let wake = ClaudeWakeRecipe { + spawner, + claude_session_id: session_id.clone(), + cwd: None, + extra_args: Vec::new(), + env: Vec::new(), + }; + let config = SessionConfig { + idle_ttl_ms: Some(idle_ttl_ms), + ..Default::default() + }; + Self::spawn(session_id, ClaudeAdapter::new(), io, config, wake).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ContentBlock; + use crate::backend::types::CommandMeta; + use crate::backend::{McpServerSpec, McpTransport, SessionInit}; + use crate::testing::FakeAgentIo; + use futures_util::StreamExt; + + /// The seam MUST hand claude a bare valid UUID for `--session-id`/`--resume` + /// (a non-UUID makes claude exit 1 "Invalid session ID"). A prefixed logical + /// id (our `conv_` conversation id) is therefore minted into a fresh + /// UUID; a logical id that already IS a UUID (the F1 factory mints one + /// upstream) passes through verbatim so production behavior is unchanged. + #[test] + fn claude_session_id_minted_for_non_uuid_passthrough_for_uuid() { + // Non-UUID logical id (prefixed conv id) → minted into a valid UUID. + let minted = claude_session_id_for("conv_0192f0a1-1111-7abc-8def-000000000000"); + assert!( + uuid::Uuid::parse_str(&minted).is_ok(), + "a non-UUID logical id must be minted into a valid UUID, got {minted:?}" + ); + let plain = claude_session_id_for("live-claude-xyz"); + assert!( + uuid::Uuid::parse_str(&plain).is_ok(), + "any non-UUID is minted, got {plain:?}" + ); + + // A bare UUID logical id passes through UNCHANGED (production F1 path). + let uuid = "8cd37cd6-2e88-4c8d-847a-7b237ffa9710"; + assert_eq!( + claude_session_id_for(uuid), + uuid, + "a logical id that is already a UUID must pass through verbatim" + ); + } + + /// SECURITY regression: a default (empty-init, no explicit mode) SessionConfig + /// produces EXACTLY `["--permission-mode", "default", "--allow-dangerously-skip-permissions"]`. + /// `--permission-mode default` (NOT zero flags) keeps an unconfigured session gated — + /// omitting it makes claude headless default to bypassPermissions (LIVE-PROBED). + /// `--allow-dangerously-skip-permissions` only UNLOCKS a later in-band switch to + /// bypass; it does NOT change the initial mode (default still enforces — LIVE-PROBED + /// 2.1.185), so the fail-closed default is preserved while runtime bypass is reachable. + #[test] + fn build_claude_init_args_empty_config_defaults_permission_mode() { + let config = SessionConfig::default(); + assert_eq!( + build_claude_init_args(&config), + vec![ + "--permission-mode".to_string(), + "default".to_string(), + "--allow-dangerously-skip-permissions".to_string(), + "--disallowed-tools".to_string(), + "AskUserQuestion".to_string(), + ], + "an unconfigured claude session is gated as `default` (never silently bypassed), \ + with runtime-bypass UNLOCKED but not activated, and AskUserQuestion denied \ + (temporary — no multi-question frontend renderer yet)" + ); + assert_eq!(build_claude_mcp_config(&[]), None, "no servers → no --mcp-config"); + } + + /// MCP servers → `--mcp-config ` + `--strict-mcp-config` (the latter ONLY + /// alongside --mcp-config). The JSON is claude's MAP shape keyed by server name, + /// stdio carrying command/args/env. + #[test] + fn build_claude_init_args_mcp_emits_strict_and_map_json() { + let config = SessionConfig { + init: SessionInit { + mcp_servers: vec![McpServerSpec { + name: "fs".into(), + transport: McpTransport::Stdio { + command: "/usr/bin/mcp-fs".into(), + args: vec!["--root".into(), "/tmp".into()], + env: vec![("TOKEN".into(), "abc".into())], + }, + }], + ..Default::default() + }, + ..Default::default() + }; + let args = build_claude_init_args(&config); + // --mcp-config --strict-mcp-config, in that order, adjacent. + let i = args + .iter() + .position(|a| a == "--mcp-config") + .expect("--mcp-config present"); + assert_eq!( + args.get(i + 2).map(String::as_str), + Some("--strict-mcp-config"), + "--strict-mcp-config must immediately follow the --mcp-config value" + ); + let json: serde_json::Value = serde_json::from_str(&args[i + 1]).expect("valid mcp-config json"); + assert_eq!(json["mcpServers"]["fs"]["command"], "/usr/bin/mcp-fs"); + assert_eq!(json["mcpServers"]["fs"]["args"][0], "--root"); + assert_eq!(json["mcpServers"]["fs"]["env"]["TOKEN"], "abc"); + } + + /// `--strict-mcp-config` must NEVER appear without `--mcp-config` (stripping the + /// machine's ambient `~/.claude` servers when we inject none would silently + /// disable a user's machine-level config). + #[test] + fn build_claude_init_args_no_strict_without_mcp() { + let config = SessionConfig { + model: Some("opus".into()), + ..Default::default() + }; + let args = build_claude_init_args(&config); + assert!( + !args.iter().any(|a| a == "--strict-mcp-config"), + "no --strict-mcp-config without --mcp-config" + ); + } + + /// preset_context → `--system-prompt`; model → `--model`; mode → + /// `--permission-mode`; each omitted independently when its source is empty. + #[test] + fn build_claude_init_args_threads_preset_model_mode() { + let config = SessionConfig { + model: Some("global.anthropic.claude-opus-4-8".into()), + mode: Some("plan".into()), + init: SessionInit { + preset_context: Some("[Assistant Rules] be precise".into()), + ..Default::default() + }, + ..Default::default() + }; + let args = build_claude_init_args(&config); + let pair = |flag: &str| -> Option { + args.iter() + .position(|a| a == flag) + .and_then(|i| args.get(i + 1).cloned()) + }; + assert_eq!(pair("--system-prompt").as_deref(), Some("[Assistant Rules] be precise")); + assert_eq!(pair("--model").as_deref(), Some("global.anthropic.claude-opus-4-8")); + assert_eq!(pair("--permission-mode").as_deref(), Some("plan")); + + // Whitespace-only / empty model & preset are omitted (not emitted as blank + // flags), but `--permission-mode` is the SECURITY exception: a blank/missing + // mode falls through to `default`, never to claude's bypass default. So the + // only flag a fully-blank config emits is `["--permission-mode", "default"]`. + let blank = SessionConfig { + model: Some("".into()), + mode: Some(" ".into()), + init: SessionInit { + preset_context: Some(" ".into()), + ..Default::default() + }, + ..Default::default() + }; + let blank_args = build_claude_init_args(&blank); + assert!( + !blank_args.iter().any(|a| a == "--model" || a == "--system-prompt"), + "blank model/preset emit no flags" + ); + assert_eq!( + blank_args, + vec![ + "--permission-mode".to_string(), + "default".to_string(), + "--allow-dangerously-skip-permissions".to_string(), + "--disallowed-tools".to_string(), + "AskUserQuestion".to_string(), + ], + "a blank mode is gated as `default` (never silently bypassed); the unlock flag \ + is always present so a later in-band switch to bypass is accepted; \ + AskUserQuestion is denied (temporary)" + ); + } + + /// claude-mode-gating: an UNRECOGNIZED `--permission-mode` value makes claude exit 1 + /// at spawn (LIVE-PROBED), surfacing as an opaque crash. `config.mode` is sourced + /// from unconstrained storage (a persisted `current_mode_id`, an assistant default, + /// a stale generic alias), so `build_claude_init_args` must validate it against + /// claude's exact enum and fall back to the fail-CLOSED `default` — never pass an + /// invalid value through to the flag. Mirrors the ACP path's + /// `clear_invalid_desired_mode` (drop-if-not-in-catalog). + #[test] + fn build_claude_init_args_invalid_mode_falls_back_to_default() { + let permission_mode = |mode: &str| -> Option { + let cfg = SessionConfig { + mode: Some(mode.to_string()), + ..Default::default() + }; + let args = build_claude_init_args(&cfg); + args.iter() + .position(|a| a == "--permission-mode") + .and_then(|i| args.get(i + 1).cloned()) + }; + // Every valid enum value passes through verbatim. This is claude's full + // accepted set (SDK `PermissionMode` + CLI): a SUPERSET of the advertised + // picker — `auto`/`dontAsk` are legal wire values (CLI-accepted, live-probed) + // even though `auto` is not advertised, so a resumed session carrying either + // must pass through, never crash. + for valid in ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"] { + assert_eq!( + permission_mode(valid).as_deref(), + Some(valid), + "valid mode {valid:?} must pass through unchanged" + ); + } + // Anything else (a stale alias, a codex-ism, free text, the CLI-only `manual` + // alias we never emit) falls back to `default` instead of crashing the spawn. + for invalid in ["yolo", "yoloNoSandbox", "manual", "acceptedits", "danger", "Plan"] { + assert_eq!( + permission_mode(invalid).as_deref(), + Some("default"), + "invalid mode {invalid:?} must fall back to `default` (not crash the spawn)" + ); + } + } + + /// http/sse MCP transports map to claude's `{type,url,headers}` entry shape. + #[test] + fn build_claude_mcp_config_http_carries_type_and_headers() { + let json_str = build_claude_mcp_config(&[McpServerSpec { + name: "api".into(), + transport: McpTransport::Http { + url: "https://example.com/mcp".into(), + headers: vec![("Authorization".into(), "Bearer x".into())], + }, + }]) + .expect("http server → some json"); + let json: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + assert_eq!(json["mcpServers"]["api"]["type"], "http"); + assert_eq!(json["mcpServers"]["api"]["url"], "https://example.com/mcp"); + assert_eq!(json["mcpServers"]["api"]["headers"]["Authorization"], "Bearer x"); + } + + /// SESS-INIT-17 (audit): duplicate MCP server NAMES collapse by construction. + /// `build_claude_mcp_config` builds claude's map shape keyed by `name` + /// (`map.insert(name, …)`), so two specs sharing a name yield ONE entry, last + /// spec wins — there is no pre-wire "reject duplicates" gate (the design never + /// mandated one; the map collapse + `--strict-mcp-config` is the contract). This + /// pins that dedup-by-map-collapse so a future refactor to a shape that could + /// emit duplicate keys (e.g. a JSON array) trips RED. + #[test] + fn build_claude_mcp_config_duplicate_names_collapse_last_wins() { + let json_str = build_claude_mcp_config(&[ + McpServerSpec { + name: "fs".into(), + transport: McpTransport::Stdio { + command: "/first".into(), + args: vec![], + env: vec![], + }, + }, + McpServerSpec { + name: "fs".into(), // same name → collapses + transport: McpTransport::Stdio { + command: "/second".into(), + args: vec![], + env: vec![], + }, + }, + ]) + .expect("two servers → some json"); + let json: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + let servers = json["mcpServers"].as_object().expect("mcpServers is a map"); + assert_eq!( + servers.len(), + 1, + "duplicate server names collapse to ONE map entry (no duplicate keys on the wire), got {servers:?}" + ); + assert_eq!( + json["mcpServers"]["fs"]["command"], "/second", + "the LATER spec wins on a name collision (map insert last-wins)" + ); + } + + /// `prepend_args` keeps init flags BEFORE caller `extra_args` (a duplicate caller + /// flag then wins by appearing later on the CLI). + #[test] + fn prepend_args_orders_init_before_caller() { + let head = vec!["--model".to_string(), "opus".to_string()]; + let tail = vec!["--model".to_string(), "sonnet".to_string()]; + assert_eq!( + prepend_args(&head, &tail), + vec!["--model", "opus", "--model", "sonnet"], + "init flags first, caller flags after (caller wins by position)" + ); + } + + /// `to_legacy_spec` maps the two-id SessionSpec correctly: Fresh mints a + /// valid-UUID claude id under the FRESH flag; Resume with a bound + /// backend_session_id resumes THAT id verbatim; Resume with a lost binding + /// rebinds a fresh valid-UUID claude session. The logical id (demux key) is + /// preserved in every arm. + #[test] + fn to_legacy_spec_mints_uuid_and_preserves_logical_id() { + // Fresh, non-UUID logical id → Fresh(), logical id preserved. + let (logical, claude_id, legacy) = ClaudeConnection::to_legacy_spec(&SessionSpec::Fresh { + session_id: "conv_abc".into(), + }); + assert_eq!(logical, "conv_abc", "logical demux key is preserved"); + assert!(uuid::Uuid::parse_str(&claude_id).is_ok(), "claude id is a valid UUID"); + match legacy { + LegacySessionSpec::Fresh(id) => assert_eq!(id, claude_id, "Fresh spawns with the minted claude id"), + other => panic!("Fresh logical → Fresh legacy, got {other:?}"), + } + + // Resume with a bound backend id → Resume(that id) verbatim (claude already + // echoed a valid UUID via BackendBound). + let (logical, claude_id, legacy) = ClaudeConnection::to_legacy_spec(&SessionSpec::Resume { + session_id: "conv_abc".into(), + backend_session_id: Some("8cd37cd6-2e88-4c8d-847a-7b237ffa9710".into()), + }); + assert_eq!(logical, "conv_abc"); + assert_eq!(claude_id, "8cd37cd6-2e88-4c8d-847a-7b237ffa9710"); + match legacy { + LegacySessionSpec::Resume(id) => assert_eq!(id, "8cd37cd6-2e88-4c8d-847a-7b237ffa9710"), + other => panic!("bound Resume → Resume legacy, got {other:?}"), + } + + // Resume with a LOST backend id → rebind a FRESH valid-UUID claude session. + let (_logical, claude_id, legacy) = ClaudeConnection::to_legacy_spec(&SessionSpec::Resume { + session_id: "conv_abc".into(), + backend_session_id: None, + }); + assert!( + uuid::Uuid::parse_str(&claude_id).is_ok(), + "lost resume rebinds a valid UUID" + ); + assert!( + matches!(legacy, LegacySessionSpec::Fresh(ref id) if id == &claude_id), + "lost backend session → Fresh rebind with the minted id, got {legacy:?}" + ); + } + + /// §C5 wiring verification: drive a full claude turn through the new seam over a + /// FakeAgentIo — dispatch(Send) delivers the prompt + bumps turn_gen, claude's + /// REPLAY of our uuid (--replay-user-messages) surfaces PromptAccepted (B, the + /// Native ack that replaced the old flush-ok synthesized emit), and the reader + /// surfaces the fixture's events wrapped in SessionEnvelope, ending with Detached + /// on EOF. + #[tokio::test] + async fn dispatch_send_drives_turn_and_emits_envelopes() { + let fixture = concat!( + // claude replays our user frame with the uuid we stamped (= client_msg_id + // "m1") → the reader's sniff_replay_prompt_ack emits PromptAccepted{m1}. + r#"{"type":"user","uuid":"m1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}"#, + "\n", + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}"#, + "\n", + r#"{"type":"result","subtype":"success","is_error":false,"result":"hi"}"#, + "\n", + ) + .as_bytes() + .to_vec(); + let fake = FakeAgentIo::new( + fixture, + Some(crate::event::ExitStatusLite { + code: Some(0), + signal: None, + }), + ); + // The process exits after emitting its frames; pre-arm the exit gate so + // the reader's wait_for_exit resolves once stdout EOFs (it only checks the + // flag AFTER draining the fixture). Models "claude prints then exits". + fake.release_exit(); + let backend = ClaudeSessionBackend::build_with_io("logical-1", Box::new(fake)).await; + let mut events = backend.events(); + + let receipt = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("hello".into())], + metadata: CommandMeta { + client_msg_id: Some("m1".into()), + ..Default::default() + }, + }) + .await + .expect("dispatch accepted"); + assert!(receipt.accepted); + assert_eq!(receipt.turn_gen, 1, "first Send bumps turn_gen to 1"); + assert_eq!(receipt.admission, Admission::Started); + + let mut saw_prompt_accepted = false; + let mut saw_message_delta = false; + let mut saw_turn_result = false; + let mut saw_detached = false; + for _ in 0..20 { + match tokio::time::timeout(std::time::Duration::from_secs(2), events.next()).await { + Ok(Some(env)) => { + assert_eq!(env.session_id, "logical-1", "every envelope demuxes by logical id"); + match env.event { + SessionEvent::PromptAccepted { ref client_msg_id } => { + assert_eq!(client_msg_id, "m1"); + saw_prompt_accepted = true; + } + SessionEvent::MessageDelta { .. } => saw_message_delta = true, + SessionEvent::TurnResult { .. } => saw_turn_result = true, + SessionEvent::Detached { .. } => { + saw_detached = true; + break; + } + _ => {} + } + } + _ => break, + } + } + assert!( + saw_prompt_accepted, + "PromptAccepted delivered from claude's uuid replay (B)" + ); + assert!(saw_message_delta, "fixture assistant text surfaced as MessageDelta"); + assert!(saw_turn_result, "fixture result surfaced as TurnResult"); + assert!(saw_detached, "EOF surfaced as Detached"); + } + + /// first-send-race-500 #2: a first `deliver_prompt` that fails because the + /// just-spawned process is not ready yet (here a degenerate spawn with no stdin) + /// must classify as the RETRYABLE `HandshakeTimeout` (→ BackendUnavailable → 502 + /// "agent starting, retry"), NOT a bare `Transport`→500. Keyed on `turn_gen == 0` + /// (no prompt has landed yet = the agent may still be coming up). + #[tokio::test] + async fn first_send_failure_before_ready_is_retryable_handshake_timeout() { + let backend = ClaudeSessionBackend::build_with_io("first-send", Box::new(FakeAgentIo::no_stdio())).await; + let res = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("hi".into())], + metadata: CommandMeta { + client_msg_id: Some("m1".into()), + ..Default::default() + }, + }) + .await; + assert!( + matches!(&res, Err(BackendError::HandshakeTimeout(m)) if m.contains("claude still starting")), + "a first-send failure before readiness must be retryable HandshakeTimeout, got {res:?}" + ); + } + + /// first-send-race-500 #2 (negative half): once a send HAS succeeded + /// (`turn_gen > 0`), a later delivery failure is an established process dropping a + /// write = genuinely broken → it stays an honest `Transport` (terminal), never + /// masked as a retryable startup race. MUTATION-PROVEN: make the wrap classify + /// unconditionally as HandshakeTimeout and this assertion fails. + #[tokio::test] + async fn delivery_failure_after_first_success_stays_transport_not_retryable() { + // First send succeeds over a real fake stdin (turn_gen → 1); then we drop the + // stdin slot to force the SECOND send into the "stdin unavailable" arm. + let backend = + ClaudeSessionBackend::build_with_io("post-ready", Box::new(FakeAgentIo::never_exits(Vec::new()))).await; + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("one".into())], + metadata: CommandMeta { + client_msg_id: Some("m1".into()), + ..Default::default() + }, + }) + .await + .expect("first send accepted (turn_gen → 1)"); + // Drop the live stdin so the next delivery fails like a broken pipe. + *backend.stdin.lock().await = None; + let res = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("two".into())], + metadata: CommandMeta { + client_msg_id: Some("m2".into()), + ..Default::default() + }, + }) + .await; + assert!( + matches!(&res, Err(BackendError::Transport(_))), + "a delivery failure AFTER the first successful send must stay Transport (honest terminal), got {res:?}" + ); + } + + /// Resume-hang startup guard: a `--resume` whose on-disk session is a broken husk + /// hangs the claude process — it emits ZERO frames and never EOFs. The reader's + /// STARTUP-ONLY zero-frame timeout must fire and surface a terminal `Detached` so + /// the FSM folds Error{Crashed}, the UI unlocks, and the next get_or_build + /// evicts+self-heals — instead of parking forever in `read`. + /// MUTATION-PROVEN: drop the startup `timeout` wrap and this test hangs (the outer + /// 5s guard fails) — a bare `read().await` never returns on a zero-frame hang. + #[tokio::test] + async fn zero_frame_hung_startup_times_out_to_terminal_detached() { + // never_exits + a gated tail never released = empty prefix (zero frames), + // stdout stays open (never EOFs), exit never fires → a true startup hang. + let fake = FakeAgentIo::never_exits(Vec::new()).with_gated_tail(b"unused".to_vec()); + // Short budget so the guard fires fast instead of waiting the real 30s. + // SAFETY: restored below; the assertion is about the TERMINAL, not the value. + let saved = std::env::var("AIONUI_HANDSHAKE_TIMEOUT_SECS").ok(); + unsafe { std::env::set_var("AIONUI_HANDSHAKE_TIMEOUT_SECS", "1") }; + + let backend = ClaudeSessionBackend::build_with_io("hung-1", Box::new(fake)).await; + let mut events = backend.events(); + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("hello".into())], + metadata: CommandMeta { + client_msg_id: Some("m1".into()), + ..Default::default() + }, + }) + .await + .expect("dispatch accepted"); + + let mut saw_detached = false; + for _ in 0..20 { + match tokio::time::timeout(std::time::Duration::from_secs(5), events.next()).await { + Ok(Some(env)) => { + if let SessionEvent::Detached { exit, .. } = env.event { + // A hang exit is unknown (we never wait_for_exit) → None → + // reducer maps it to Error{Crashed} (the unlock terminal). + assert_eq!(exit, None, "a zero-frame hang reports unknown exit (None)"); + saw_detached = true; + break; + } + } + _ => break, + } + } + + match saved { + Some(v) => unsafe { std::env::set_var("AIONUI_HANDSHAKE_TIMEOUT_SECS", v) }, + None => unsafe { std::env::remove_var("AIONUI_HANDSHAKE_TIMEOUT_SECS") }, + } + assert!( + saw_detached, + "a zero-frame hung startup must surface a terminal Detached (guard), not park forever" + ); + } + + /// Owner-decision tripwire: once the process has produced its FIRST frame it is + /// proven alive, so a subsequent mid-turn stall must NOT be timed out — a long + /// turn that thinks/runs tools silently for longer than the budget is normal and + /// must keep running, never be killed. Here the prefix emits one assistant frame + /// (disarms the startup guard) then the gated tail is never released (a silent + /// stall longer than the 1s budget). The reader must stay parked WITHOUT emitting + /// a terminal — no premature Detached. MUTATION-PROVEN: make the read stay bounded + /// after the first frame (drop the `if seen_frame` unbounded branch) and a + /// spurious Detached appears → this assertion fails. + #[tokio::test] + async fn first_frame_disarms_startup_guard_long_silent_turn_not_killed() { + let prefix = concat!( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}"#, + "\n" + ) + .as_bytes() + .to_vec(); + // Prefix flows immediately (one frame → seen_frame latches); the gated tail is + // NEVER released → the process then goes silent for longer than the budget. + let fake = FakeAgentIo::never_exits(prefix).with_gated_tail(b"never-sent".to_vec()); + let saved = std::env::var("AIONUI_HANDSHAKE_TIMEOUT_SECS").ok(); + unsafe { std::env::set_var("AIONUI_HANDSHAKE_TIMEOUT_SECS", "1") }; + + let backend = ClaudeSessionBackend::build_with_io("alive-1", Box::new(fake)).await; + let mut events = backend.events(); + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("hello".into())], + metadata: CommandMeta { + client_msg_id: Some("m1".into()), + ..Default::default() + }, + }) + .await + .expect("dispatch accepted"); + + // Drain a few events; we must see the assistant frame but NEVER a Detached, + // even after waiting well past the 1s budget (the stall is not timed). + let mut saw_message = false; + let mut saw_terminal = false; + for _ in 0..10 { + // A timeout slice (no event) just means the turn is still silently + // running — keep waiting past the budget; only an actual event matters. + if let Ok(Some(env)) = tokio::time::timeout(std::time::Duration::from_millis(400), events.next()).await { + match env.event { + SessionEvent::MessageDelta { .. } => saw_message = true, + SessionEvent::Detached { .. } | SessionEvent::TurnResult { .. } => { + saw_terminal = true; + break; + } + _ => {} + } + } + } + + match saved { + Some(v) => unsafe { std::env::set_var("AIONUI_HANDSHAKE_TIMEOUT_SECS", v) }, + None => unsafe { std::env::remove_var("AIONUI_HANDSHAKE_TIMEOUT_SECS") }, + } + assert!( + saw_message, + "the first assistant frame must surface (proves the process is alive)" + ); + assert!( + !saw_terminal, + "a long SILENT turn (alive, just slow) must NOT be timed out after the first frame" + ); + } + + /// Windows pipe-EOF gap: after the first frame proves the process alive + /// (`seen_frame` latched), the process EXITS but its stdout NEVER EOFs — modelling + /// a surviving grandchild (detached MCP/tool descendant) that inherited the write + /// handle and keeps the pipe's write end open, so `stdout.read()` never returns + /// `Ok(0)`. The reader must NOT park forever: the exit-watch leg of the read race + /// wins, and a terminal `Detached` fires carrying the captured exit status → the + /// reducer folds Error{Crashed}/CleanNoResult → the UI unlocks instead of wedging + /// at `pending` with no error. + /// + /// This is the mirror of `first_frame_disarms_startup_guard_long_silent_turn_not_killed`: + /// there the process is ALIVE (never_exits) and must stay parked; here the process + /// is GONE (release_exit) and must terminate. The two together pin the exact + /// boundary — terminate on real exit, never on mere silence. + /// MUTATION-PROVEN: revert the `seen_frame` branch to a bare `stdout.read().await` + /// (drop the exit-watch select) and this test hangs (the process exited but the + /// pipe never EOFs → no terminal → the 3s guard fails). + #[tokio::test] + async fn process_exit_without_eof_surfaces_terminal_detached() { + let prefix = concat!( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"partial"}]}}"#, + "\n" + ) + .as_bytes() + .to_vec(); + // Prefix flows immediately (one frame → seen_frame latches). The gated tail is + // NEVER released → the writer parks holding the duplex open, so stdout NEVER + // EOFs (the Windows inherited-handle case). But the process DOES exit + // (release_exit), which is the orthogonal signal the reader must react to. + let fake = FakeAgentIo::new( + prefix, + Some(crate::event::ExitStatusLite { + code: Some(137), // SIGKILL-style exit, as a `taskkill`'d leaf would report + signal: None, + }), + ) + .with_gated_tail(b"never-released".to_vec()); + fake.release_exit(); // the process is gone, even though stdout stays open + + let backend = ClaudeSessionBackend::build_with_io("win-eof-1", Box::new(fake)).await; + let mut events = backend.events(); + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("hello".into())], + metadata: CommandMeta { + client_msg_id: Some("m1".into()), + ..Default::default() + }, + }) + .await + .expect("dispatch accepted"); + + let mut saw_message = false; + let mut detached_exit: Option> = None; + for _ in 0..20 { + match tokio::time::timeout(std::time::Duration::from_secs(3), events.next()).await { + Ok(Some(env)) => match env.event { + SessionEvent::MessageDelta { .. } => saw_message = true, + SessionEvent::Detached { exit, .. } => { + detached_exit = Some(exit); + break; + } + _ => {} + }, + _ => break, + } + } + + assert!( + saw_message, + "the pre-exit assistant frame must surface (proves seen_frame)" + ); + assert_eq!( + detached_exit, + Some(Some(crate::event::ExitStatusLite { + code: Some(137), + signal: None, + })), + "process exit without EOF must surface a terminal Detached reusing the captured exit status" + ); + } + + /// G2 tripwire: when a backend process exits with allowlisted stderr (e.g. a + /// usage-limit line), the terminal `Detached` carries the REDACTED summary so + /// the conversation layer can tell the user *why* — and a non-allowlisted + /// secret-bearing line is NEVER surfaced. The redaction happens at the backend + /// boundary (`redact_exit_stderr`), so raw stderr never crosses into the event. + #[tokio::test] + async fn detached_carries_redacted_stderr_summary_on_crash() { + // No stdout frames; the process just dies after writing stderr. + let fake = FakeAgentIo::new( + Vec::new(), + Some(crate::event::ExitStatusLite { + code: Some(1), + signal: None, + }), + ) + .with_stderr( + "DEBUG bootstrap: loaded ANTHROPIC_API_KEY=sk-ant-0123456789abcdef\n\ + ERROR codex_acp::thread: You've hit your usage limit, try again later", + ); + fake.release_exit(); + let backend = ClaudeSessionBackend::build_with_io("logical-g2", Box::new(fake)).await; + let mut events = backend.events(); + + let mut redacted: Option> = None; + for _ in 0..10 { + match tokio::time::timeout(std::time::Duration::from_secs(2), events.next()).await { + Ok(Some(env)) => { + if let SessionEvent::Detached { redacted_summary, .. } = env.event { + redacted = Some(redacted_summary); + break; + } + } + _ => break, + } + } + let summary = redacted + .expect("a Detached must arrive") + .expect("allowlisted stderr must yield a redacted summary"); + assert!( + summary.contains("usage limit"), + "the allowlisted reason surfaces; got {summary}" + ); + assert!( + !summary.contains("sk-ant"), + "the secret on the non-allowlisted line must never leak; got {summary}" + ); + } + + /// 009 R1a: a final `result` frame truncated mid-write (no trailing newline — + /// e.g. the process was SIGKILLed/OOM'd while flushing it) must NOT be + /// silently dropped. The reader's EOF tail-flush parses the trailing + /// half-line as a final frame, so its TurnResult still surfaces BEFORE the + /// Detached. Reverse control: without the flush, only the `\n`-terminated + /// assistant frame would surface and the turn's result would vanish. + #[tokio::test] + async fn truncated_final_result_is_flushed_at_eof_not_lost() { + let fixture = { + let mut v = Vec::new(); + v.extend_from_slice( + concat!( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"the answer is 42"}]}}"#, + "\n", + // Final result frame WITH NO TRAILING NEWLINE — truncated write. + r#"{"type":"result","subtype":"success","is_error":false,"result":"42"}"#, + ) + .as_bytes(), + ); + v + }; + // SIGKILL exit (signal 9) models the OOM/kill that truncated the write. + let fake = FakeAgentIo::new( + fixture, + Some(crate::event::ExitStatusLite { + code: None, + signal: Some(9), + }), + ); + fake.release_exit(); + let backend = ClaudeSessionBackend::build_with_io("trunc-1", Box::new(fake)).await; + let mut events = backend.events(); + let _ = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("q".into())], + metadata: CommandMeta::default(), + }) + .await + .expect("dispatch accepted"); + + let mut saw_turn_result = false; + let mut turn_result_before_detached = false; + for _ in 0..20 { + match tokio::time::timeout(std::time::Duration::from_secs(2), events.next()).await { + Ok(Some(env)) => match env.event { + SessionEvent::TurnResult { .. } => saw_turn_result = true, + SessionEvent::Detached { .. } => { + turn_result_before_detached = saw_turn_result; + break; + } + _ => {} + }, + _ => break, + } + } + assert!( + saw_turn_result, + "the truncated final result frame must be flushed at EOF, not silently dropped" + ); + assert!( + turn_result_before_detached, + "the flushed TurnResult must arrive BEFORE the terminal Detached (drain-before-honor)" + ); + } + + #[tokio::test] + async fn unsupported_commands_are_rejected_by_capability() { + // Reject matrix: every cap=false command MUST return the EXACT + // CommandNotSupported{command} — never silently accept (Layer-2 rule). + // SetMode/SetModel are NO LONGER here (G2 wired the in-band switch → cap=true, + // dispatch accepts); their accept path is covered by set_mode/set_model tests. + let io = Box::new(FakeAgentIo::never_exits(Vec::new())); + let backend = ClaudeSessionBackend::build_with_io("s", io).await; + let caps = backend.capabilities(); + // cap honesty: each rejected command is advertised false. + assert!(!caps.supported_commands.rewind); + assert!(!caps.supported_commands.list_checkpoints); + assert!(!caps.supported_commands.answer_auth); + assert!(!caps.supported_commands.steer); + // G2: set_mode/set_model are now advertised TRUE (wired in-band). + assert!(caps.supported_commands.set_mode); + assert!(caps.supported_commands.set_model); + assert!(!caps.supported_commands.cancel_tool); + // Attachment caps: image + resource are now advertised TRUE (deliver_prompt + // emits a native base64 image block / a Read-tool path ref); audio + + // at_mention remain false (no working claude input path). + assert!(caps.prompt_blocks.image, "image cap true (native base64 block)"); + assert!(caps.prompt_blocks.resource, "resource cap true (Read-tool path ref)"); + assert!(!caps.prompt_blocks.audio); + assert!(!caps.prompt_blocks.at_mention); + + assert!(matches!( + backend.dispatch(Command::Rewind { num_turns: 1 }).await, + Err(BackendError::CommandNotSupported { command: "rewind" }) + )); + assert!(matches!( + backend.dispatch(Command::ListCheckpoints).await, + Err(BackendError::CommandNotSupported { + command: "list_checkpoints" + }) + )); + assert!(matches!( + backend + .dispatch(Command::AnswerAuth { + method_id: "x".into(), + credentials: serde_json::Value::Null + }) + .await, + Err(BackendError::CommandNotSupported { command: "answer_auth" }) + )); + assert!(matches!( + backend.dispatch(Command::Steer { content: Vec::new() }).await, + Err(BackendError::CommandNotSupported { command: "steer" }) + )); + assert!(matches!( + backend + .dispatch(Command::Cancel { + target: CancelTarget::Tool("t".into()) + }) + .await, + Err(BackendError::CommandNotSupported { command: "cancel_tool" }) + )); + // Un-advertised content blocks are still rejected before wire-write + // (audio / at_mention), keyed on their content_block: name. + assert!(matches!( + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Audio { + data: vec![0], + media_type: "audio/wav".into() + }], + metadata: CommandMeta::default(), + }) + .await, + Err(BackendError::CommandNotSupported { + command: "content_block:audio" + }) + )); + assert!(matches!( + backend + .dispatch(Command::Send { + content: vec![ContentBlock::AtMention { user_id: "u1".into() }], + metadata: CommandMeta::default(), + }) + .await, + Err(BackendError::CommandNotSupported { + command: "content_block:at_mention" + }) + )); + } + + /// `Acknowledge` (user-ack of a done-unseen turn) is accepted as a pure no-op: + /// claude has no "acknowledge" wire concept — it folds at the conversation + /// fold-on-read layer, never the backend (§C1). It must NOT be rejected + /// (cap_behavior excludes it from the gated set) and must NOT write any frame + /// or open a turn — `NoTurn`, no stdin write. (The only claude dispatch arm + /// without its own test before this; closes the claude dispatch-arm coverage.) + #[tokio::test] + async fn acknowledge_is_accepted_as_noturn_noop_no_wire() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend = ClaudeSessionBackend::build_with_io("s-ack", Box::new(fake)).await; + + let receipt = backend + .dispatch(Command::Acknowledge { node_id: "n-1".into() }) + .await + .expect("Acknowledge is always accepted (never CommandNotSupported)"); + assert!(receipt.accepted); + assert_eq!( + receipt.admission, + Admission::NoTurn, + "Acknowledge folds at read layer; it must not open a turn" + ); + + // Give any (erroneous) async write a chance to land, then assert stdin stayed empty. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let written = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + assert!( + written.trim().is_empty(), + "Acknowledge must write NOTHING to the claude wire, got: {written:?}" + ); + } + + /// §C5 HARD acceptance: claude parse ZERO-DIFF. The new ClaudeSessionBackend + /// MUST surface exactly the SessionEvent sequence the legacy + /// `ClaudeAdapter::parse_chunk` produces for the same bytes — the wrapping + /// (envelope/turn_gen/reader) must not add, drop, reorder, or mutate any + /// parsed event. (Both paths share the same parser, so this pins the WRAPPING + /// invariant — the only place the new path could diverge.) + #[tokio::test] + async fn claude_parse_is_zero_diff_vs_legacy() { + // A realistic F1-shape multi-frame turn (the shape claude --print emits + // without --include-partial-messages): system noise, an assistant text + + // tool_use, a user tool_result, and the terminal result. + let frames = [ + r#"{"type":"system","subtype":"init","session_id":"s","tools":[]}"#, + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"working"}]}}"#, + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}"#, + r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1"}]}}"#, + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"done"}]}}"#, + r#"{"type":"result","subtype":"success","is_error":false,"result":"done"}"#, + ]; + let bytes: Vec = format!("{}\n", frames.join("\n")).into_bytes(); + + // (a) LEGACY ground truth: feed the bytes straight through parse_chunk. + let legacy_events: Vec = { + let mut parser = ClaudeAdapter::new(); + parser.parse_chunk(&bytes) + }; + assert!(!legacy_events.is_empty(), "fixture must produce events"); + + // (b) NEW path: drive the same bytes through ClaudeSessionBackend; collect + // the parsed events (unwrapped from envelopes), EXCLUDING the wrapper-only + // additions the new seam legitimately introduces (synthesized + // PromptAccepted from dispatch, and the EOF Detached the reader appends). + let fake = FakeAgentIo::new( + bytes.clone(), + Some(crate::event::ExitStatusLite { + code: Some(0), + signal: None, + }), + ); + fake.release_exit(); + let backend = ClaudeSessionBackend::build_with_io("logical-1", Box::new(fake)).await; + let mut events = backend.events(); + // No PromptAccepted here: dispatch with client_msg_id:None (so the only + // events are the parsed ones + the terminal Detached). + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("go".into())], + metadata: CommandMeta::default(), + }) + .await + .expect("accepted"); + + let mut new_events: Vec = Vec::new(); + for _ in 0..50 { + match tokio::time::timeout(std::time::Duration::from_secs(2), events.next()).await { + Ok(Some(env)) => match env.event { + SessionEvent::Detached { .. } => break, // reader's EOF marker (wrapper-only) + // wrapper-only reader/dispatch additions (NOT from parse_chunk): + SessionEvent::PromptAccepted { .. } + | SessionEvent::BackendBound { .. } + | SessionEvent::SubagentUpdate { .. } => continue, + ev => new_events.push(ev), + }, + _ => break, + } + } + + // ZERO-DIFF: the parsed event sequence is identical. + assert_eq!( + new_events, legacy_events, + "ClaudeSessionBackend must surface the legacy parse sequence verbatim \ + (wrapping adds only the dispatch PromptAccepted + EOF Detached)" + ); + } + + /// §C5 HARD acceptance over a REAL captured fixture (claude 2.1.169, a real + /// single-tool subagent turn, 15 frames). Same zero-diff invariant against a + /// production-shape byte stream — pins that real frame volume/ordering + /// survives the wrapping unchanged. + #[tokio::test] + async fn claude_parse_zero_diff_over_real_fixture() { + let bytes = include_str!("../../tests/fixtures/claude_2.1.169_single_tool_turn.ndjson") + .as_bytes() + .to_vec(); + + let legacy_events: Vec = { + let mut parser = ClaudeAdapter::new(); + parser.parse_chunk(&bytes) + }; + assert!(legacy_events.len() >= 3, "real fixture must produce several events"); + + let fake = FakeAgentIo::new( + bytes.clone(), + Some(crate::event::ExitStatusLite { + code: Some(0), + signal: None, + }), + ); + fake.release_exit(); + let backend = ClaudeSessionBackend::build_with_io("real-1", Box::new(fake)).await; + let mut events = backend.events(); + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("go".into())], + metadata: CommandMeta::default(), + }) + .await + .expect("accepted"); + + let mut new_events: Vec = Vec::new(); + for _ in 0..100 { + match tokio::time::timeout(std::time::Duration::from_secs(3), events.next()).await { + Ok(Some(env)) => match env.event { + SessionEvent::Detached { .. } => break, + // Reader-side WRAPPER additions (NOT from parse_chunk): synthesized + // PromptAccepted, B-CLAUDE-INIT Provisioning + BackendBound from + // the raw system/init frame, SubagentUpdate sniffed from the raw + // system/task_* frames, and ConfigChanged sniffed from the raw + // system/init|status permissionMode (sniff_mode — the real fixture's + // init carries permissionMode:bypassPermissions). The zero-diff + // contract is over the PARSED stream, so these reader-side sniffs are + // excluded. + SessionEvent::PromptAccepted { .. } + | SessionEvent::Provisioning { .. } + | SessionEvent::BackendBound { .. } + | SessionEvent::ConfigChanged { .. } + | SessionEvent::SubagentUpdate { .. } => continue, + ev => new_events.push(ev), + }, + _ => break, + } + } + + assert_eq!( + new_events, legacy_events, + "real-fixture parse must be verbatim through the new seam (excl. reader-side PromptAccepted + Provisioning)" + ); + + // 009 H5 load-bearing: the zero-diff assert above only proves old==new — it + // would PASS even if subagent attribution were dropped on BOTH legs (the + // exact "froze unattributed as covered" trap the test-coverage audit §4 + // flagged). This fixture's subagent frames carry + // parent_tool_use_id=toolu_bdrk_01AnD5Af6r9vYWvADBW8tCqt, so a correctly + // attributing parser MUST surface it on a ToolCall/ToolResult. Revert the + // adapter's top-level parent_tool_use_id read → every parent becomes None → + // this fails. + let attributed = new_events.iter().any(|e| { + matches!( + e, + SessionEvent::ToolCall { parent_tool_use_id: Some(p), .. } + | SessionEvent::ToolResult { parent_tool_use_id: Some(p), .. } + if p == "toolu_bdrk_01AnD5Af6r9vYWvADBW8tCqt" + ) + }); + assert!( + attributed, + "H5: a subagent tool step must carry its frame parent_tool_use_id, got {new_events:?}" + ); + } + + /// MAJOR-1 (codex-M2 mirror): AnswerPermission MUST write the keyed + /// control_response to stdin AND broadcast PermissionResolved — not silently + /// accept-and-drop (which wedges the can_use_tool turn forever). Feeds a + /// can_use_tool control_request (so the reader registers it), answers it, and + /// asserts both effects. + #[tokio::test] + async fn answer_permission_writes_control_response_and_resolves() { + let fixture = concat!( + r#"{"type":"control_request","request_id":"req-7","request":{"subtype":"can_use_tool","tool_name":"Bash","tool_use_id":"toolu-7","input":{"command":"ls"}}}"#, + "\n", + ) + .as_bytes() + .to_vec(); + // never_exits: the persistent process stays alive so we can answer + read + // back what we wrote on stdin. + let fake = FakeAgentIo::never_exits(fixture); + let captured = fake.captured_stdin(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + + // Wait for the reader to surface Permission{request_id} (pending registered). + let mut events = backend.events(); + let saw_perm = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::Permission { ref request_id, .. } if request_id == "req-7") { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!(saw_perm, "can_use_tool surfaced as Permission{{request_id}}"); + + let receipt = backend + .dispatch(Command::AnswerPermission { + request_id: "req-7".into(), + decision: super::super::types::PermissionDecision::Approved, + selected: None, + answers: Vec::new(), + }) + .await + .expect("answer accepted"); + assert_eq!(receipt.admission, Admission::NoTurn); + + // (a) a control_response keyed to req-7 + echoing toolUseID hit stdin. + let written = { + let mut s = String::new(); + for _ in 0..40 { + s = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + if s.contains("control_response") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + s + }; + assert!( + written.contains(r#""type":"control_response""#), + "wrote control_response, got: {written}" + ); + assert!( + written.contains(r#""request_id":"req-7""#), + "echoes the request_id, got: {written}" + ); + assert!( + written.contains(r#""toolUseID":"toolu-7""#), + "echoes the toolUseID, got: {written}" + ); + assert!( + written.contains(r#""behavior":"allow""#), + "Approved → allow, got: {written}" + ); + // DEPTH (anti-shallow-assertion): a plain-tool allow MUST carry updatedInput + // (a record) — claude's stdio schema ZodErrors without it and the approved + // tool never runs. This end-to-end test drives a Bash allow, so it must + // assert the frame field, not just the {type,id,behavior} shell (the gap + // that let the missing-updatedInput regression ship). Echoes the original + // input {"command":"ls"}. + assert!( + written.contains(r#""updatedInput""#) && written.contains(r#""command":"ls""#), + "plain-tool allow frame MUST carry updatedInput == original input (ZodError guard), got: {written}" + ); + + // (b) PermissionResolved broadcast (FSM leaves requires-action). + let saw_resolved = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::PermissionResolved { ref request_id, .. } if request_id == "req-7") + { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!(saw_resolved, "AnswerPermission broadcasts PermissionResolved{{req-7}}"); + } + + /// REST-recovery source: `pending_permission_requests()` lists the OUTSTANDING + /// permission (request_id + tool_name) after a `can_use_tool` arrives, and is + /// EMPTY after `AnswerPermission` consumes it. This is the data + /// `GET /confirmations` projects to rebuild a reloaded permission card; the + /// answer-clears-it half proves the list never shows an already-answered card. + #[tokio::test] + async fn pending_permission_requests_lists_open_then_clears_on_answer() { + let fixture = concat!( + r#"{"type":"control_request","request_id":"req-9","request":{"subtype":"can_use_tool","tool_name":"Bash","tool_use_id":"toolu-9","input":{"command":"ls"}}}"#, + "\n", + ) + .as_bytes() + .to_vec(); + let fake = FakeAgentIo::never_exits(fixture); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + + // Wait for the reader to register the pending permission. + let mut events = backend.events(); + let saw = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::Permission { ref request_id, .. } if request_id == "req-9") { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!(saw, "can_use_tool registered the pending permission"); + + // The recovery view lists it: request_id + tool_name, NO raw input exposed. + let pending = backend.pending_permission_requests(); + assert_eq!(pending.len(), 1, "one outstanding permission, got {pending:?}"); + assert_eq!(pending[0].request_id, "req-9"); + assert_eq!(pending[0].tool_name, "Bash"); + + // Answering it removes it from the pending set → recovery lists nothing + // (the card is no longer outstanding, so it must not re-surface on reload). + backend + .dispatch(Command::AnswerPermission { + request_id: "req-9".into(), + decision: super::super::types::PermissionDecision::Approved, + selected: None, + answers: Vec::new(), + }) + .await + .expect("answer accepted"); + assert!( + backend.pending_permission_requests().is_empty(), + "answered permission no longer outstanding" + ); + } + + /// G-A regression: `dispatch(Cancel)` MUST write a `control_request{subtype: + /// "interrupt"}` to the retained stdin — the WIRE-OUT oracle the old live test + /// lacked (it only asserted the FSM folds to Idle, which the reducer does + /// unconditionally, so a no-op Cancel stub passed). This pins "cancel actually + /// interrupts the long-lived claude", not just "our side unlocked". (Equivalent of + /// the deleted legacy `cancel_writes_interrupt_control_request_to_stdin`.) + #[tokio::test] + async fn cancel_writes_interrupt_control_request_to_stdin() { + use super::super::types::CancelTarget; + // never_exits: the persistent process stays alive so we can read back stdin. + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + + let receipt = backend + .dispatch(Command::Cancel { + target: CancelTarget::Turn, + }) + .await + .expect("cancel accepted"); + assert_eq!(receipt.admission, Admission::NoTurn); + + let written = { + let mut s = String::new(); + for _ in 0..40 { + s = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + if s.contains("interrupt") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + s + }; + assert!( + written.contains(r#""type":"control_request""#), + "cancel wrote a control_request to stdin (not a no-op stub), got: {written:?}" + ); + assert!( + written.contains(r#""subtype":"interrupt""#), + "cancel's control_request is an interrupt, got: {written:?}" + ); + } + + #[tokio::test] + async fn answer_permission_unknown_request_is_rejected() { + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(Vec::new()))).await; + let err = backend + .dispatch(Command::AnswerPermission { + request_id: "nope".into(), + decision: super::super::types::PermissionDecision::Denied, + selected: None, + answers: Vec::new(), + }) + .await + .expect_err("no pending → reject (not silent-accept)"); + assert!(matches!(err, BackendError::Transport(m) if m.contains("no pending permission"))); + } + + /// race-audit conn-10: claude can RETRACT an outstanding `can_use_tool` via a + /// `control_cancel_request` (e.g. a hook resolved it, or the turn was + /// interrupted) BEFORE the user answers. The reader must drop the pending entry + /// so a subsequently-arriving `AnswerPermission` sees None and is REJECTED — + /// never builds a stale `control_response` for a request claude no longer + /// awaits (which would desync the CLI). The retract must be TARGETED: a second, + /// un-retracted permission stays answerable. + /// + /// Determinism: the reader is a single in-order task and the retract emits no + /// SessionEvent, so a second permission (req-2) fed AFTER the req-1 cancel acts + /// as a sequencing barrier — observing Permission{req-2} proves req-1's + /// control_cancel_request was already consumed. + #[tokio::test] + async fn control_cancel_request_retracts_pending_so_answer_is_rejected() { + let fixture = concat!( + // 1) req-1 can_use_tool → Permission{req-1}, registers pending[req-1]. + r#"{"type":"control_request","request_id":"req-1","request":{"subtype":"can_use_tool","tool_name":"Bash","tool_use_id":"toolu-1","input":{"command":"ls"}}}"#, + "\n", + // 2) claude RETRACTS req-1 → reader removes pending[req-1] (no event). + r#"{"type":"control_cancel_request","request_id":"req-1"}"#, + "\n", + // 3) req-2 can_use_tool → Permission{req-2}; observing this proves the + // in-order reader already processed the req-1 retract above. + r#"{"type":"control_request","request_id":"req-2","request":{"subtype":"can_use_tool","tool_name":"Write","tool_use_id":"toolu-2","input":{"file":"x"}}}"#, + "\n", + ) + .as_bytes() + .to_vec(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(fixture))).await; + let mut events = backend.events(); + + // Barrier: wait for Permission{req-2} (⇒ req-1's retract already consumed). + let saw_req2 = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::Permission { ref request_id, .. } if request_id == "req-2") { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!( + saw_req2, + "req-2 permission surfaced (sequencing barrier past the req-1 retract)" + ); + + // Answering the RETRACTED req-1 must be rejected (pending was dropped) — + // NOT silently answered with a stale control_response. + let err = backend + .dispatch(Command::AnswerPermission { + request_id: "req-1".into(), + decision: super::super::types::PermissionDecision::Approved, + selected: None, + answers: Vec::new(), + }) + .await + .expect_err("retracted req-1 → no pending → reject"); + assert!( + matches!(err, BackendError::Transport(m) if m.contains("no pending permission")), + "retracted permission must reject, not build a stale control_response" + ); + + // The retract is TARGETED: req-2 (never retracted) is still answerable. + let receipt = backend + .dispatch(Command::AnswerPermission { + request_id: "req-2".into(), + decision: super::super::types::PermissionDecision::Approved, + selected: None, + answers: Vec::new(), + }) + .await + .expect("un-retracted req-2 still answerable (retract was not a blanket wipe)"); + assert_eq!(receipt.admission, Admission::NoTurn); + } + + /// G2: SetMode / SetModel while IDLE (no turn in flight) write the in-band + /// control_request to stdin IMMEDIATELY. Proves cap=true ↔ dispatch accepts + the + /// real wire shape (probe-verified control_request{subtype:set_permission_mode|set_model}). + /// + /// Confirmation semantics (design §9.10.1): SetModel emits ConfigChanged + /// OPTIMISTICALLY (its ack carries no model echo, Optimistic tier); SetMode does + /// NOT (de-optimistic — confirmed by the inbound system/status, see + /// `claude_advertises_fixed_modes_and_remembers_mode_from_status` + + /// `sniff_mode_emits_config_changed_from_system_status`). Here we assert the wire + /// frames + the SetModel optimistic ConfigChanged; SetMode's ConfigChanged is NOT + /// expected at dispatch. + #[tokio::test] + async fn set_mode_and_model_write_in_band_control_request() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + let mut events = backend.events(); + + // SetMode (idle) → immediate control_request{set_permission_mode}, NO ConfigChanged. + let receipt = backend + .dispatch(Command::SetMode { mode: "plan".into() }) + .await + .expect("SetMode accepted (cap=true)"); + assert_eq!(receipt.admission, Admission::NoTurn); + + // SetModel (idle) → control_request{set_model} + OPTIMISTIC ConfigChanged{model}. + backend + .dispatch(Command::SetModel { model: "sonnet".into() }) + .await + .expect("SetModel accepted (cap=true)"); + let cfg = tokio::time::timeout(std::time::Duration::from_secs(1), async { + while let Some(env) = events.next().await { + if let SessionEvent::ConfigChanged { mode, model } = env.event { + return Some((mode, model)); + } + } + None + }) + .await + .expect("a ConfigChanged emitted"); + // The ONLY ConfigChanged at dispatch is SetModel's optimistic model emit — + // SetMode emits none (de-optimistic), so the first ConfigChanged is model:sonnet. + assert_eq!( + cfg, + Some((None, Some("sonnet".to_string()))), + "SetModel → optimistic ConfigChanged{{model:sonnet}}; SetMode emits no ConfigChanged at dispatch" + ); + + let written = { + let mut s = String::new(); + for _ in 0..40 { + s = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + if s.contains("set_permission_mode") && s.contains("set_model") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + s + }; + assert!( + written.contains(r#""type":"control_request""#), + "in-band switch is a control_request, got: {written}" + ); + assert!( + written.contains(r#""subtype":"set_permission_mode""#) && written.contains(r#""mode":"plan""#), + "set_permission_mode frame on the wire, got: {written}" + ); + assert!( + written.contains(r#""subtype":"set_model""#) && written.contains(r#""model":"sonnet""#), + "set_model frame on the wire, got: {written}" + ); + } + + /// #99: SetConfigOption{effort} writes the in-band + /// `control_request{apply_flag_settings, settings:{effortLevel}}` (LIVE-PROBED + /// 2.1.181 — NOT set_effort). A non-effort option id rejects (cap=false ↔ reject). + #[tokio::test] + async fn set_config_option_effort_writes_apply_flag_settings() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + + let receipt = backend + .dispatch(Command::SetConfigOption { + option_id: "effort".into(), + value: "high".into(), + }) + .await + .expect("effort SetConfigOption accepted"); + assert_eq!(receipt.admission, Admission::NoTurn); + + let written = { + let mut s = String::new(); + for _ in 0..40 { + s = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + if s.contains("apply_flag_settings") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + s + }; + assert!( + written.contains(r#""subtype":"apply_flag_settings""#) && written.contains(r#""effortLevel":"high""#), + "effort → apply_flag_settings{{effortLevel}} on the wire, got: {written}" + ); + + // CP-1: claude does not echo effort back, so the backend must REMEMBER it → + // capabilities().current_effort reflects the last-set level (this is the + // genuinely-new state; model/mode are backend-reported, effort is not). + assert_eq!( + backend.capabilities().current_effort.as_deref(), + Some("high"), + "the backend remembers the set effort for current_effort" + ); + + // A non-effort generic option id is rejected (no claude wire for it). + let err = backend + .dispatch(Command::SetConfigOption { + option_id: "verbosity".into(), + value: "loud".into(), + }) + .await + .expect_err("unknown config option → CommandNotSupported"); + assert!(matches!(err, BackendError::CommandNotSupported { command } if command == "set_config_option")); + } + + /// #1 effort catalog validation (ACP `clear_invalid_desired_*` ported to effort). + /// Once the initialize control_response has advertised a model with a bounded + /// `supportedEffortLevels` set, a `SetConfigOption{effort}` for a level OUTSIDE that + /// set is REJECTED (BadRequest-style Transport error) instead of being written and + /// poisoning `current_effort` — while a level INSIDE the set still applies. Before + /// the catalog lands (empty), any level is permissive (matches the empty-catalog + /// semantics of ACP `is_*_valid`, covered by the test above). + #[tokio::test] + async fn set_config_option_effort_validates_against_model_catalog() { + // Catalog: one model advertising only low/medium/high (NO "max"). + let init_resp = r#"{"type":"control_response","response":{"subtype":"success","request_id":"ctl-1","response":{"models":[{"value":"default","displayName":"Default","supportedEffortLevels":["low","medium","high"]}]}}}"#; + let fake = FakeAgentIo::never_exits(format!("{init_resp}\n").into_bytes()); + let captured = fake.captured_stdin(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + let _events = backend.events(); + // Wait for the catalog to land. + for _ in 0..40 { + if !backend.capabilities().available_models.is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + // An UNSUPPORTED level ("max") is rejected — no wire, no current_effort poison. + let err = backend + .dispatch(Command::SetConfigOption { + option_id: "effort".into(), + value: "max".into(), + }) + .await + .expect_err("effort not in the model's catalog → rejected"); + assert!( + matches!(err, BackendError::Transport(msg) if msg.contains("not supported")), + "unsupported effort must be rejected as an error" + ); + assert!( + backend.capabilities().current_effort.is_none(), + "a rejected effort must NOT poison current_effort" + ); + + // A SUPPORTED level ("high") still applies. + backend + .dispatch(Command::SetConfigOption { + option_id: "effort".into(), + value: "high".into(), + }) + .await + .expect("a catalog-valid effort is accepted"); + let written = { + let mut s = String::new(); + for _ in 0..40 { + s = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + if s.contains("apply_flag_settings") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + s + }; + assert!( + written.contains(r#""effortLevel":"high""#), + "a valid effort reaches the wire, got: {written}" + ); + assert_eq!(backend.capabilities().current_effort.as_deref(), Some("high")); + } + + /// `ultracode` is surfaced as an effort level for xhigh-capable models (mirroring the + /// CLI's own picker entry) but dispatches the DEDICATED boolean flag + /// `apply_flag_settings{settings:{ultracode:true}}` — NOT `effortLevel:"ultracode"` + /// (which our own `effort_is_supported` gate would reject since it is absent from + /// `supportedEffortLevels`). Wire LIVE-PROBED 2.1.206 + /// (samples/claude-cli/2.1.206/ultracode_wire.result.md). + #[tokio::test] + async fn set_config_option_ultracode_writes_boolean_flag() { + // Catalog: a model advertising xhigh → fill_discovery injects "ultracode". + let init_resp = r#"{"type":"control_response","response":{"subtype":"success","request_id":"ctl-1","response":{"models":[{"value":"default","displayName":"Default","supportedEffortLevels":["low","medium","high","xhigh"]}]}}}"#; + let fake = FakeAgentIo::never_exits(format!("{init_resp}\n").into_bytes()); + let captured = fake.captured_stdin(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + let _events = backend.events(); + for _ in 0..40 { + if !backend.capabilities().available_models.is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + // The synthetic level is advertised (so the picker shows it AND the gate passes). + assert!( + backend + .capabilities() + .available_models + .iter() + .any(|m| m.reasoning_efforts.iter().any(|e| e == "ultracode")), + "ultracode must be injected into an xhigh-capable model's efforts" + ); + + backend + .dispatch(Command::SetConfigOption { + option_id: "reasoning_effort".into(), + value: "ultracode".into(), + }) + .await + .expect("ultracode SetConfigOption accepted"); + + let written = { + let mut s = String::new(); + for _ in 0..40 { + s = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + if s.contains("apply_flag_settings") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + s + }; + assert!( + written.contains(r#""subtype":"apply_flag_settings""#) && written.contains(r#""ultracode":true"#), + "ultracode → apply_flag_settings{{ultracode:true}} on the wire, got: {written}" + ); + assert!( + !written.contains(r#""effortLevel":"ultracode""#), + "ultracode must NOT ride the effortLevel field, got: {written}" + ); + assert_eq!( + backend.capabilities().current_effort.as_deref(), + Some("ultracode"), + "the backend remembers ultracode for the picker highlight" + ); + } + + /// `ultracode` is injected ONLY for xhigh-capable models — a model that tops out at + /// `high` must NOT gain the synthetic level (matches the CLI gate: ultracode requires + /// xhigh). Guards against offering a level the model can never engage. + #[tokio::test] + async fn ultracode_not_injected_for_non_xhigh_model() { + let init_resp = r#"{"type":"control_response","response":{"subtype":"success","request_id":"ctl-1","response":{"models":[{"value":"default","displayName":"Default","supportedEffortLevels":["low","medium","high"]}]}}}"#; + let fake = FakeAgentIo::never_exits(format!("{init_resp}\n").into_bytes()); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + let _events = backend.events(); + for _ in 0..40 { + if !backend.capabilities().available_models.is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!( + backend + .capabilities() + .available_models + .iter() + .all(|m| m.reasoning_efforts.iter().all(|e| e != "ultracode")), + "a non-xhigh model must not advertise ultracode" + ); + // And the gate rejects it (not in this model's catalog). + let err = backend + .dispatch(Command::SetConfigOption { + option_id: "reasoning_effort".into(), + value: "ultracode".into(), + }) + .await + .expect_err("ultracode not offered by a non-xhigh model → rejected"); + assert!(matches!(err, BackendError::Transport(msg) if msg.contains("not supported"))); + } + + /// Mode read/advertise parity: claude advertises its permission modes in + /// `available_modes` (so the picker has data), VERBATIM-EQUIVALENT to the legacy + /// ACP bridge `buildAvailableModes` — same ids, same order (default, acceptEdits, + /// plan, dontAsk, bypassPermissions). `auto` is omitted because the bridge gates it + /// on `supportsAutoMode`, which the direct CLI never reports (see + /// `claude_permission_modes`). current_mode is now REMEMBERED from claude's inbound + /// `system/status{permissionMode}` (design §9.10.1 option A — de-optimistic), NOT + /// optimistically at dispatch. This drives a system/status through the reader (as + /// claude emits when a mode actually applies) and asserts current_mode reflects it. + #[tokio::test] + async fn claude_advertises_fixed_modes_and_remembers_mode_from_status() { + // The fake emits a system/status{permissionMode:plan} (the real applied-mode + // signal, shape from protocols/samples/claude-cli/2.1.187/_all_autonomous_mode.jsonl). + let status = r#"{"type":"system","subtype":"status","permissionMode":"plan","session_id":"s"}"#; + let fake = FakeAgentIo::never_exits(format!("{status}\n").into_bytes()); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + + // The advertised modes carry the EXACT wire ids claude accepts, in the legacy + // bridge's order. `auto` is gated out (see claude_permission_modes doc). + let caps = backend.capabilities(); + let ids: Vec<&str> = caps.available_modes.iter().map(|m| m.id.as_str()).collect(); + assert_eq!( + ids, + vec!["default", "acceptEdits", "plan", "dontAsk", "bypassPermissions"], + "claude advertises the legacy-equivalent permission-mode picker (picker data source)" + ); + assert!( + caps.available_modes + .iter() + .all(|m| !m.name.is_empty() && m.description.is_some()), + "each mode carries display name + description" + ); + + // Subscribe drives the reader; it consumes the system/status → sniff_mode sets + // current_mode_override. Poll until the merge lands. + let _events = backend.events(); + let mut cur = backend.capabilities().current_mode; + for _ in 0..40 { + if cur.as_deref() == Some("plan") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + cur = backend.capabilities().current_mode; + } + assert_eq!( + cur.as_deref(), + Some("plan"), + "current_mode reflects the inbound system/status applied mode (not an optimistic dispatch value)" + ); + } + + /// G2: a SetModel issued WHILE A TURN IS IN FLIGHT is QUEUED (not written + /// mid-turn, which would truncate the turn) and drained over stdin BEFORE the + /// next prompt — so the switch applies to the next turn. Proves the queue + + /// drain ordering: the control_request bytes precede the next user prompt bytes. + #[tokio::test] + async fn set_model_mid_turn_is_queued_and_drained_before_next_prompt() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + + // First Send → turn_in_flight=true (the reader never sees a terminal here, + // never_exits + no fixture, so the flag stays set: models "mid-turn"). + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("first".into())], + metadata: CommandMeta::default(), + }) + .await + .expect("first Send accepted"); + + // SetModel now → QUEUED (turn in flight), nothing new on the wire yet. + backend + .dispatch(Command::SetModel { model: "opus".into() }) + .await + .expect("SetModel accepted (queued)"); + let before = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + assert!( + !before.contains("set_model"), + "mid-turn SetModel must NOT write to the wire yet (queued), got: {before}" + ); + + // Next Send drains the queued control_request BEFORE writing the prompt. + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("second".into())], + metadata: CommandMeta::default(), + }) + .await + .expect("second Send accepted"); + + let written = { + let mut s = String::new(); + for _ in 0..40 { + s = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + if s.contains("set_model") && s.contains("second") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + s + }; + let set_model_at = written.find("set_model").expect("queued set_model drained to wire"); + let second_prompt_at = written.find("second").expect("second prompt on wire"); + assert!( + set_model_at < second_prompt_at, + "the queued set_model must be drained BEFORE the next prompt (else it truncates the turn), got: {written}" + ); + } + + /// G2: repeated mid-turn switches of the SAME kind collapse to the latest + /// (last-write-wins de-dup) — only the final model is drained, not every one. + #[tokio::test] + async fn mid_turn_same_kind_switches_dedup_to_latest() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("first".into())], + metadata: CommandMeta::default(), + }) + .await + .expect("first Send"); + // Three SetModel mid-turn → only the last survives the de-dup. + for m in ["sonnet", "haiku", "opus"] { + backend + .dispatch(Command::SetModel { model: m.into() }) + .await + .expect("SetModel queued"); + } + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("second".into())], + metadata: CommandMeta::default(), + }) + .await + .expect("second Send"); + let written = { + let mut s = String::new(); + for _ in 0..40 { + s = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + if s.contains("opus") && s.contains("second") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + s + }; + assert!( + written.contains(r#""model":"opus""#), + "latest model survives, got: {written}" + ); + assert!( + !written.contains(r#""model":"sonnet""#) && !written.contains(r#""model":"haiku""#), + "earlier same-kind switches are de-duped away, got: {written}" + ); + } + + /// 2A: the chosen AskUserQuestion option label (Command::AnswerPermission.selected) + /// MUST ride into `updatedInput.answers:{question: }` — NOT the + /// first-option degrade. Proves a user picking the 2nd option is answered correctly. + #[test] + fn build_control_response_uses_selected_label_over_first_option() { + use super::super::types::PermissionDecision; + let pending = PendingPerm { + tool_use_id: "toolu-1".into(), + tool_name: "AskUserQuestion".into(), + input: serde_json::json!({ + "questions": [{ + "question": "Pick one", + "options": [{"label": "Alpha"}, {"label": "Beta"}] + }] + }), + }; + // User picked "Beta" (the SECOND option) — must be answered, not "Alpha". + let resp = build_control_response("req-1", &pending, PermissionDecision::Approved, Some("Beta"), &[]); + let answers = &resp["response"]["response"]["updatedInput"]["answers"]; + assert_eq!( + answers["Pick one"], "Beta", + "explicit selected label is the answer, got: {resp}" + ); + assert_eq!(resp["response"]["response"]["behavior"], "allow"); + assert_eq!(resp["response"]["response"]["toolUseID"], "toolu-1"); + + // No `selected` → degrade to the first option (a plain allow). + let degraded = build_control_response("req-1", &pending, PermissionDecision::Approved, None, &[]); + assert_eq!( + degraded["response"]["response"]["updatedInput"]["answers"]["Pick one"], "Alpha", + "None selected → first-option degrade" + ); + + // Denied ignores the label entirely → deny body. + let denied = build_control_response("req-1", &pending, PermissionDecision::Denied, Some("Beta"), &[]); + assert_eq!(denied["response"]["response"]["behavior"], "deny"); + } + + /// Task #83 (load-bearing): claude can ask MULTIPLE questions in one call and a + /// question can be `multiSelect:true`. The full per-question `answers` set MUST + /// cover EVERY question (keyed by question text), with a multi-select value + /// emitted as a JSON ARRAY of labels — the live-captured 2.1.178 wire + /// (`protocols/samples/claude-cli/2.1.178/ask_user_question_multi_array.ndjson`). + /// Reverting `build_ask_user_question_answers` to the old `questions.first()` + /// single-answer path makes this test fail (only the first question answered, + /// no array) — the regression guard for the silent under-answer bug. + #[test] + fn build_control_response_answers_all_questions_with_multiselect_array() { + use super::super::types::{PermissionDecision, QuestionAnswer}; + // Two questions: a single-select + a multiSelect — the exact shape claude + // emits (see the array fixture's control_request input). + let pending = PendingPerm { + tool_use_id: "toolu-9".into(), + tool_name: "AskUserQuestion".into(), + input: serde_json::json!({ + "questions": [ + { "question": "Which language?", "header": "Language", + "options": [{"label": "Rust"}, {"label": "Go"}, {"label": "TypeScript"}], + "multiSelect": false }, + { "question": "Which features do you want?", "header": "Features", + "options": [{"label": "Auth"}, {"label": "Logging"}, {"label": "Metrics"}], + "multiSelect": true } + ] + }), + }; + let answers = vec![ + QuestionAnswer { + question: "Which language?".into(), + labels: vec!["Rust".into()], + }, + QuestionAnswer { + question: "Which features do you want?".into(), + labels: vec!["Auth".into(), "Logging".into()], + }, + ]; + let resp = build_control_response("req-9", &pending, PermissionDecision::Approved, None, &answers); + let updated = &resp["response"]["response"]["updatedInput"]; + let ans = &updated["answers"]; + + // EVERY question is answered (both keys present) — not just the first. + assert_eq!( + ans["Which language?"], "Rust", + "single-select → bare label, got: {resp}" + ); + // multi-select → JSON ARRAY of labels (claude joins it with ", " itself). + assert_eq!( + ans["Which features do you want?"], + serde_json::json!(["Auth", "Logging"]), + "multi-select → array of labels, got: {resp}" + ); + assert_eq!( + ans.as_object().map(serde_json::Map::len), + Some(2), + "all questions answered (no silent under-answer), got: {resp}" + ); + // The original input (questions) is preserved alongside the injected answers. + assert!(updated["questions"].is_array(), "original input echoed"); + assert_eq!(resp["response"]["response"]["behavior"], "allow"); + assert_eq!(resp["response"]["response"]["toolUseID"], "toolu-9"); + } + + /// REGRESSION (the ZodError that killed every plain-tool approval): allowing a + /// NON-AskUserQuestion tool (Bash/Write/Edit) MUST include `updatedInput` (a + /// record) — claude's stdio control-response schema rejects an allow branch + /// without it (`expected record, received undefined`), so the approved tool never + /// runs. The plain-tool allow branch previously emitted only {behavior, toolUseID}. + /// This was the coverage blind spot: the only permission test exercised + /// AskUserQuestion (which always carries updatedInput), so the plain-tool path + /// shipped untested. Echo the original input unchanged. + #[test] + fn build_control_response_plain_tool_allow_carries_updated_input() { + use super::super::types::PermissionDecision; + let pending = PendingPerm { + tool_use_id: "toolu-bash".into(), + tool_name: "Bash".into(), + input: serde_json::json!({ "command": "ls" }), + }; + let resp = build_control_response("req-bash", &pending, PermissionDecision::Approved, None, &[]); + let body = &resp["response"]["response"]; + assert_eq!(body["behavior"], "allow"); + assert_eq!(body["toolUseID"], "toolu-bash"); + // updatedInput MUST be present (a record) and equal the original input — + // never null/undefined (that is the exact ZodError trigger). + assert!( + body["updatedInput"].is_object(), + "plain-tool allow MUST carry updatedInput as a record (ZodError guard), got: {resp}" + ); + assert_eq!( + body["updatedInput"]["command"], "ls", + "original tool input echoed unchanged" + ); + } + + /// Defensive: a non-object tool input still yields a valid `{}` record (never + /// `undefined`), so the allow frame can't re-trigger the union failure. + #[test] + fn build_control_response_plain_tool_allow_non_object_input_falls_back_to_empty_record() { + use super::super::types::PermissionDecision; + let pending = PendingPerm { + tool_use_id: "toolu-x".into(), + tool_name: "Weird".into(), + input: serde_json::json!("not-an-object"), + }; + let resp = build_control_response("req-x", &pending, PermissionDecision::Approved, None, &[]); + let updated = &resp["response"]["response"]["updatedInput"]; + assert!( + updated.is_object() && updated.as_object().unwrap().is_empty(), + "fallback {{}} record, got: {updated}" + ); + } + + #[tokio::test] + async fn dropping_backend_aborts_reader() { + // MAJOR-3 (codex-M5 mirror): drop must abort the reader so a mid-turn / + // hung-claude process is reaped (never_exits models the no-EOF case). + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(Vec::new()))).await; + let handle = backend + .suspend + .current_abort_handle() + .expect("live reader has an abort handle"); + assert!(!handle.is_finished(), "reader live (blocked on read) before drop"); + drop(backend); + for _ in 0..40 { + if handle.is_finished() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!( + handle.is_finished(), + "dropping the backend aborts the reader (M5 parity)" + ); + } + + #[tokio::test] + async fn b_claude_init_captures_current_model_and_emits_mcp_provisioning() { + // B-CLAUDE-INIT: the system/init frame's `model` → capabilities().current_model + // (config supplied none via build_with_io), and mcp_servers[] → Provisioning + // events (connected→ToolsReady, failed→LoadFailed, needs-auth→Degraded). + let init = r#"{"type":"system","subtype":"init","session_id":"s","model":"global.anthropic.claude-opus-4-8","tools":[],"mcp_servers":[{"name":"ok","status":"connected"},{"name":"bad","status":"failed"},{"name":"auth","status":"needs-auth"}]}"#; + let bytes = format!("{init}\n").into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + let mut events = backend.events(); + + // Collect the Provisioning events the reader emits from the init mcp_servers. + let mut phases = Vec::new(); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if let SessionEvent::Provisioning { phase } = env.event { + phases.push(phase); + if phases.len() == 3 { + return; + } + } + } + }) + .await; + assert_eq!(phases.len(), 3, "one Provisioning per mcp server, got {phases:?}"); + assert!( + phases + .iter() + .any(|p| matches!(p, crate::event::ProvisioningPhase::ToolsReady)), + "connected→ToolsReady" + ); + assert!( + phases + .iter() + .any(|p| matches!(p, crate::event::ProvisioningPhase::LoadFailed { .. })), + "failed→LoadFailed" + ); + assert!( + phases + .iter() + .any(|p| matches!(p, crate::event::ProvisioningPhase::Degraded { .. })), + "needs-auth→Degraded" + ); + // current_model captured from init (config gave none). + assert_eq!( + backend.capabilities().current_model.as_deref(), + Some("global.anthropic.claude-opus-4-8"), + "init model → capabilities().current_model" + ); + } + + #[tokio::test] + async fn b_claude_init_does_not_override_config_model() { + // config model is authoritative: when build_with_io seeds a model (it does + // not — defaults None — so we test the inverse: when config HAS a model, the + // init wire model must NOT overwrite it). build_with_io uses default config + // (None), so here we assert the wire fills it; the config-wins path is + // covered by the want_init_model gate (config.model.is_none()). + let init = r#"{"type":"system","subtype":"init","session_id":"s","model":"wire-model","tools":[]}"#; + let fake = FakeAgentIo::never_exits(format!("{init}\n").into_bytes()); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + let _events = backend.events(); + for _ in 0..40 { + if backend.capabilities().current_model.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert_eq!(backend.capabilities().current_model.as_deref(), Some("wire-model")); + } + + /// #98/#101: the reader sniffs the `control_request{initialize}` RESPONSE for the + /// selectable model list + slash commands and fills `capabilities()`. Wire shape + /// pinned from the live 2.1.181 probe (fixture + /// protocols/samples/claude-cli/2.1.181/control_initialize_response): the success + /// payload nests the init response under `response.response`, models carry + /// `value`/`displayName`/`supportedEffortLevels`, commands carry `name`/`description`. + #[tokio::test] + async fn control_initialize_response_fills_models_and_slash_commands() { + let init_resp = r#"{"type":"control_response","response":{"subtype":"success","request_id":"ctl-1","response":{"models":[{"value":"default","displayName":"Default","description":"Use the default model","supportsEffort":true,"supportedEffortLevels":["low","medium","high","max"]},{"value":"opus","displayName":"global.anthropic.claude-opus-4-8","description":"Custom Opus model"}],"commands":[{"name":"deep-research","description":"Deep research harness","argumentHint":""},{"name":"verify","description":"Verify claims"}]}}}"#; + let fake = FakeAgentIo::never_exits(format!("{init_resp}\n").into_bytes()); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + let _events = backend.events(); + // Poll until the catalog lands (the reader is async, like discovered_model). + for _ in 0..40 { + if !backend.capabilities().available_models.is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + let caps = backend.capabilities(); + // Models: value→id, displayName→name, supportedEffortLevels→reasoning_efforts. + assert_eq!(caps.available_models.len(), 2, "two models parsed"); + assert_eq!(caps.available_models[0].id, "default"); + assert_eq!(caps.available_models[0].name, "Default"); + assert_eq!( + caps.available_models[0].reasoning_efforts, + vec![ + "low".to_string(), + "medium".to_string(), + "high".to_string(), + "max".to_string() + ], + "supportedEffortLevels → reasoning_efforts (the #99 effort surface)" + ); + assert_eq!(caps.available_models[1].id, "opus"); + assert_eq!(caps.available_models[1].name, "global.anthropic.claude-opus-4-8"); + assert!( + caps.available_models[1].reasoning_efforts.is_empty(), + "a model without supportedEffortLevels → empty efforts" + ); + // Slash commands: name + description. + assert_eq!(caps.slash_commands.len(), 2, "two slash commands parsed"); + assert_eq!(caps.slash_commands[0].name, "deep-research"); + assert_eq!( + caps.slash_commands[0].description.as_deref(), + Some("Deep research harness") + ); + assert_eq!(caps.slash_commands[1].name, "verify"); + } + + /// The FIX (async catalog-arrival signal): when the `initialize` RESPONSE lands the + /// reader must BROADCAST a `CatalogUpdated` so the conversation re-projects the + /// picker — before this, the catalog silently filled `discovered_caps` with no + /// upward signal and the frontend (which read an empty `config_options` on open) + /// never re-fetched, leaving the model selector permanently disabled. Asserts the + /// event carries the parsed models AND claude's fixed permission modes (the frontend + /// replaces its whole snapshot on this frame, so the modes must ride along or the + /// mode picker would be wiped). + #[tokio::test] + async fn control_initialize_response_broadcasts_catalog_updated() { + use futures_util::StreamExt as _; + let init_resp = r#"{"type":"control_response","response":{"subtype":"success","request_id":"ctl-1","response":{"models":[{"value":"default","displayName":"Default"},{"value":"opus","displayName":"global.anthropic.claude-opus-4-8"}],"commands":[{"name":"verify","description":"Verify claims"}]}}}"#; + let fake = FakeAgentIo::never_exits(format!("{init_resp}\n").into_bytes()); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + // Subscribe BEFORE the reader drains the frame so the broadcast is observed. + let mut events = backend.events(); + + let mut catalog = None; + for _ in 0..40 { + if let Ok(Some(env)) = tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await + && let SessionEvent::CatalogUpdated { + models, + modes, + slash_commands, + } = env.event + { + catalog = Some((models, modes, slash_commands)); + break; + } + } + let (models, modes, slash_commands) = catalog.expect("a CatalogUpdated must be broadcast on initialize"); + assert_eq!(models.len(), 2, "parsed models ride the event"); + assert_eq!(models[0].id, "default"); + assert_eq!(models[1].id, "opus"); + // claude's permission modes must ride along (whole-snapshot replace), + // legacy-bridge order, `auto` gated out (see claude_permission_modes). + let mode_ids: Vec<&str> = modes.iter().map(|m| m.id.as_str()).collect(); + assert_eq!( + mode_ids, + vec!["default", "acceptEdits", "plan", "dontAsk", "bypassPermissions"], + "the permission modes ride the catalog event so the mode picker survives the snapshot replace" + ); + assert_eq!(slash_commands.len(), 1, "slash commands ride the event"); + assert_eq!(slash_commands[0].name, "verify"); + } + + /// A non-initialize success control_response (e.g. a set_model ack, which has no + /// `models`/`commands`) must NOT clobber the catalog — the request_id-free sniff + /// keys on the presence of `models`/`commands`, not on a correlation id. + #[tokio::test] + async fn non_initialize_control_response_does_not_touch_catalog() { + // A set_model-style success with no models/commands, THEN an initialize reply. + let other = r#"{"type":"control_response","response":{"subtype":"success","request_id":"ctl-7","response":{"ok":true}}}"#; + let init = r#"{"type":"control_response","response":{"subtype":"success","request_id":"ctl-1","response":{"models":[{"value":"default","displayName":"Default"}]}}}"#; + let fake = FakeAgentIo::never_exits(format!("{other}\n{init}\n").into_bytes()); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + let _events = backend.events(); + for _ in 0..40 { + if !backend.capabilities().available_models.is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + // The init reply still landed (the prior non-init success was a no-op, not a clobber). + assert_eq!(backend.capabilities().available_models.len(), 1); + assert_eq!(backend.capabilities().available_models[0].id, "default"); + } + + /// Bug-A (regression A, claude proactive=true): a TurnResult from a turn that was + /// SUPERSEDED by a proactive resend must carry that turn's OWN (older) epoch — the + /// epoch locked at its `system/init` — NOT the read-time `turn_gen` (which the + /// resend already bumped). This is the reader-level mechanism that lets the + /// reducer's cross-turn guard (result_epoch < since_epoch) drop the stale + /// `is_error` result instead of surfacing it as a spurious Error bubble. + /// + /// Sequence (hermetic, deterministic — mirrors `_all_zerogap_cancel.jsonl` C): + /// Send#1 (turn_gen 0→1) → init#1 locks turn_open_epoch=1 → Send#2/resend + /// (turn_gen 1→2) → turn-1's late result is read AFTER the bump. Without the fix + /// it would be stamped 2 (== the resend turn's since_epoch → NOT dropped). The + /// fix stamps it 1 (the open-turn epoch) so it is older than the resend turn. + #[tokio::test] + async fn bug_a_late_result_keeps_superseded_turn_epoch_not_readtime() { + // Two gated segments: [0]=turn-1's system/init, [1]=turn-1's late is_error result. + let init1 = r#"{"type":"system","subtype":"init","session_id":"s"}"#; + let late_result = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"session_id":"s"}"#; + let fake = FakeAgentIo::never_exits(Vec::new()).with_gated_segments(vec![ + format!("{init1}\n").into_bytes(), + format!("{late_result}\n").into_bytes(), + ]); + let seg = fake.segment_releaser(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + let mut events = backend.events(); + + // Send#1 → turn_gen 0→1 (the turn that will be superseded). + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("first".into())], + metadata: CommandMeta::default(), + }) + .await + .expect("Send#1 accepted"); + // Release segment 0: turn-1's system/init → reader locks turn_open_epoch = 1. + seg(); + + // Wait until the init has been observed (turn_open_epoch is now locked to 1). + // We can't read turn_open_epoch directly, so gate on a tiny settle then proceed; + // the segment gate guarantees ordering (segment 1 is not released yet). + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // Send#2 (the proactive resend) → turn_gen 1→2, BEFORE the late result is read. + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("resend".into())], + metadata: CommandMeta::default(), + }) + .await + .expect("Send#2 (resend) accepted"); + // Now release segment 1: turn-1's LATE result, read while turn_gen == 2. + seg(); + + // Collect the TurnResult envelope and assert its epoch is the SUPERSEDED turn's + // locked epoch (1), not the read-time turn_gen (2). + let tr_epoch = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::TurnResult { .. }) { + return Some(env.turn_gen); + } + } + None + }) + .await + .expect("timed out waiting for the late TurnResult") + .expect("a TurnResult envelope"); + assert_eq!( + tr_epoch, 1, + "the superseded turn's late result must carry its OWN turn-open epoch (1), \ + NOT the read-time turn_gen (2) the resend bumped to — else the reducer's \ + cross-turn guard cannot drop it (spurious Error bubble, bug-A)" + ); + } + + /// B (regression A): claude's replay of OUR stamped uuid (--replay-user-messages) + /// surfaces PromptAccepted{client_msg_id: uuid} — the Native ack that replaced the + /// flush-ok synthesized emit. A claude-MINTED user frame (tool_result content, or + /// the [Request interrupted] ghost) must NOT spuriously emit one for a top-level + /// prompt id (tool_result is skipped; a ghost's own uuid simply never matches a + /// pending client_msg_id downstream, but we also skip tool_result frames here). + #[tokio::test] + async fn replay_of_stamped_uuid_emits_prompt_accepted_minted_frames_do_not() { + let our_replay = + r#"{"type":"user","uuid":"cm-9","message":{"role":"user","content":[{"type":"text","text":"do it"}]}}"#; + // A claude-minted continuation: a tool_result user frame (carries claude's own + // uuid). Must NOT yield a PromptAccepted (skipped as a tool_result frame). + let minted_tool_result = r#"{"type":"user","uuid":"claude-mint-1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#; + let fake = FakeAgentIo::never_exits(format!("{minted_tool_result}\n{our_replay}\n").into_bytes()); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; + let mut events = backend.events(); + + // Collect PromptAccepted ids until our replay's id arrives (or timeout). The + // minted tool_result precedes it on the wire; if it wrongly emitted, we'd see + // "claude-mint-1" FIRST. + let mut accepted_ids: Vec = Vec::new(); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if let SessionEvent::PromptAccepted { client_msg_id } = env.event { + accepted_ids.push(client_msg_id.clone()); + if client_msg_id == "cm-9" { + break; + } + } + } + }) + .await; + assert_eq!( + accepted_ids, + vec!["cm-9".to_string()], + "ONLY our stamped-uuid replay emits PromptAccepted; the minted tool_result frame does not" + ); + } + + /// F-4 default: with idle_ttl=None (the production default via build_with_io), + /// the backend NEVER suspends — no idle timer, slot stays Active. Proves the + /// opt-in invariant that protects the parse zero-diff acceptance. + #[tokio::test] + async fn f4_off_by_default_no_suspension() { + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(Vec::new()))).await; + assert!(backend.idle_timer.is_none(), "no idle timer when idle_ttl is None"); + assert_eq!(backend.suspend.idle_ttl_ms(), None); + assert!(backend.suspend.is_active().await, "slot Active"); + // Even after a wait, the slot is still Active (nothing can suspend it). + tokio::time::sleep(std::time::Duration::from_millis(60)).await; + assert!( + backend.suspend.is_active().await, + "stays Active forever (production parity)" + ); + } + + /// F-4 suspend→wake: a configured idle_ttl makes the idle timer suspend the + /// idle process; the next dispatch(Send) wakes via the supplied spawner, + /// routing to `--resume ` (the resume recipe). FakeSpawner records + /// the spawn then Errs (can't make a real process), so dispatch surfaces the + /// wake error — which is the observable proof the resume path ran with the + /// right args. (A live re-spawn is a real-binary concern; the hermetic proof + /// is "the wake recipe routed `--resume ` through the injected spawner".) + #[tokio::test] + async fn f4_suspend_then_wake_routes_resume_through_spawner() { + use crate::testing::FakeSpawner; + let spawner = Arc::new(FakeSpawner::new()); + // ttl 40ms → idle_check_interval clamps to 1s; drive suspension directly to + // avoid a 1s wait, then assert wake on dispatch. + let backend = ClaudeSessionBackend::build_with_io_suspending( + "logical-resume-1", + Box::new(FakeAgentIo::never_exits(Vec::new())), + spawner.clone(), + 40, + ) + .await; + assert!(backend.idle_timer.is_some(), "idle timer spawned when ttl is Some"); + assert!(backend.suspend.is_active().await, "starts Active"); + + // Force a suspend (idle past ttl) without waiting on the 1s timer cadence. + let suspended = backend + .suspend + .suspend_if_idle(aionui_common::now_ms() + 10_000, false) + .await; + assert!(suspended, "idle past ttl → suspended"); + assert!(!backend.suspend.is_active().await, "now Dormant"); + + // The next Send must wake → route `--resume logical-resume-1` through the + // spawner. FakeSpawner Errs, so dispatch returns that wake error. + let err = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("wake up".into())], + metadata: CommandMeta::default(), + }) + .await + .expect_err("FakeSpawner cannot make a real process → wake Errs"); + assert!( + matches!(&err, BackendError::Transport(m) if m.contains("resume-spawn failed")), + "dispatch surfaced the wake re-spawn error, got {err:?}" + ); + assert_eq!( + spawner.call_count(), + 1, + "wake routed through the injected spawner exactly once" + ); + let spec = spawner.last_command().await.expect("a spawn was recorded"); + assert!( + spec.args.iter().any(|a| a == "--resume") && spec.args.iter().any(|a| a == "logical-resume-1"), + "wake spawns with `--resume ` (resume continuity), got args {:?}", + spec.args + ); + drop(backend); // idle timer + (Dormant) controller tear down cleanly + } + + /// #103: `config.spawn_env` (the cc-switch provider env the app registry fills for + /// backend == "claude") MUST reach the spawned process's `CommandSpec.env`. Before + /// this fix the adapter hardcoded `env: Vec::new()`, so a cc-switch third-party + /// relay user's claude process never saw `ANTHROPIC_BASE_URL`/`AUTH_TOKEN`. + #[tokio::test] + async fn spawn_env_is_injected_into_command_spec() { + use crate::testing::FakeSpawner; + let spawner = Arc::new(FakeSpawner::new()); + let conn = ClaudeConnection::new(spawner.clone()); + let config = SessionConfig { + spawn_env: vec![ + aionui_common::EnvVar { + name: "ANTHROPIC_BASE_URL".into(), + value: "https://relay.example".into(), + }, + aionui_common::EnvVar { + name: "ANTHROPIC_AUTH_TOKEN".into(), + value: "tok-123".into(), + }, + ], + ..Default::default() + }; + // FakeSpawner RECORDS the CommandSpec then Errs (no real process), so + // open_session surfaces a spawn error — but the spec we care about was already + // captured. (Same hermetic pattern as f4_suspend_then_wake.) + let _ = conn + .open_session( + SessionSpec::Fresh { + session_id: "11111111-1111-4111-8111-111111111111".into(), + }, + config, + ) + .await; + let spec = spawner.last_command().await.expect("a spawn was recorded"); + let base = spec.env.iter().find(|e| e.name == "ANTHROPIC_BASE_URL"); + let tok = spec.env.iter().find(|e| e.name == "ANTHROPIC_AUTH_TOKEN"); + assert_eq!(base.map(|e| e.value.as_str()), Some("https://relay.example")); + assert_eq!(tok.map(|e| e.value.as_str()), Some("tok-123")); + } + + /// #103 parity: an empty `spawn_env` (no cc-switch config, or a non-claude backend + /// the app never fills) yields an empty `CommandSpec.env` — byte-identical to the + /// pre-#103 spawn (inherit the parent env only). + #[tokio::test] + async fn empty_spawn_env_yields_empty_command_env() { + use crate::testing::FakeSpawner; + let spawner = Arc::new(FakeSpawner::new()); + let conn = ClaudeConnection::new(spawner.clone()); + let _ = conn + .open_session( + SessionSpec::Fresh { + session_id: "22222222-2222-4222-8222-222222222222".into(), + }, + SessionConfig::default(), + ) + .await; + let spec = spawner.last_command().await.expect("a spawn was recorded"); + assert!( + spec.env.is_empty(), + "no spawn_env → empty CommandSpec.env, got {:?}", + spec.env + ); + } + + /// F-4 #1-critical regression: a turn in flight (set by dispatch(Send)) must + /// prevent the idle timer from suspending the process MID-TURN — otherwise the + /// reader is aborted before it emits the terminal and the FSM strands in Running. + /// dispatch(Send) sets turn_in_flight; suspend_if_idle(.., turn_active=true) must + /// then refuse to close even though the slot is idle past the ttl. + #[tokio::test] + async fn f4_turn_in_flight_blocks_idle_suspend() { + use crate::testing::FakeSpawner; + // never_exits → the reader stays blocked (turn "in flight"); a real Send + // sets turn_in_flight=true and the fixture never emits a terminal to clear it. + let backend = ClaudeSessionBackend::build_with_io_suspending( + "logical-live-1", + Box::new(FakeAgentIo::never_exits(Vec::new())), + Arc::new(FakeSpawner::new()), + 40, + ) + .await; + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("long turn".into())], + metadata: CommandMeta::default(), + }) + .await + .expect("send accepted (slot already Active)"); + assert!( + backend.turn_in_flight.load(std::sync::atomic::Ordering::SeqCst), + "dispatch(Send) marks the turn in flight" + ); + // Idle WAY past the ttl, but turn_active=true → MUST NOT suspend. + let suspended = backend + .suspend + .suspend_if_idle(aionui_common::now_ms() + 10_000, true) + .await; + assert!(!suspended, "a live turn is never suspended even when idle past ttl"); + assert!( + backend.suspend.is_active().await, + "process kept resident for the live turn" + ); + drop(backend); + } + + #[tokio::test] + async fn sniff_task_emits_subagent_update_lifecycle() { + // §6b b1: claude system/task_* frames → SubagentUpdate (keyed by task_id, + // parent = tool_use_id, label = subagent_type/workflow_name). task_started → + // Running; task_notification{status} → terminal. The reducer upserts these + // into Running.subagents, which drives has_foreground_activity. + let frames = [ + r#"{"type":"system","subtype":"task_started","task_id":"tk-1","tool_use_id":"toolu-9","subagent_type":"general-purpose"}"#, + r#"{"type":"system","subtype":"task_notification","task_id":"tk-1","tool_use_id":"toolu-9","status":"completed"}"#, + ]; + let bytes = format!("{}\n", frames.join("\n")).into_bytes(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(bytes))).await; + let mut events = backend.events(); + + let mut updates = Vec::new(); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if let SessionEvent::SubagentUpdate { + r#ref, + status, + parent_ref, + label, + } = env.event + { + updates.push((r#ref, status, parent_ref, label)); + if updates.len() == 2 { + return; + } + } + } + }) + .await; + + assert_eq!( + updates.len(), + 2, + "task_started + task_notification → 2 SubagentUpdate, got {updates:?}" + ); + // started → Running, keyed by task_id, parent = tool_use_id, label = subagent_type. + assert_eq!(updates[0].0, "tk-1", "ref = task_id"); + assert_eq!( + updates[0].1, + crate::event::SubagentStatus::Running, + "task_started → Running" + ); + assert_eq!(updates[0].2.as_deref(), Some("toolu-9"), "parent_ref = tool_use_id"); + assert_eq!( + updates[0].3.as_deref(), + Some("general-purpose"), + "label = subagent_type" + ); + // notification completed → Completed, SAME ref (lifecycle upsert). + assert_eq!(updates[1].0, "tk-1", "same ref across the lifecycle"); + assert_eq!( + updates[1].1, + crate::event::SubagentStatus::Completed, + "status=completed → Completed" + ); + } + + /// sniff_mode: claude's AUTHORITATIVE mode signal is `permissionMode` on a + /// `system/status` frame — emitted for BOTH a user-driven set AND an autonomous + /// change (plan-exit). The reader adopts it (normal→default) as current_mode AND + /// emits ConfigChanged{mode} (design §9.10.1 option A; README #10). Wire shape from + /// protocols/samples/claude-cli/2.1.187/_all_autonomous_mode.jsonl (autonomous + /// plan-exit emitted exactly this system/status). MUTATION-PROVEN by the autonomous + /// scenario: without sniff_mode the autonomous mode change is silently dropped. + #[tokio::test] + async fn sniff_mode_emits_config_changed_from_system_status() { + // `normal` is claude's internal name for our `default` — covers the mapping too. + let frame = r#"{"type":"system","subtype":"status","permissionMode":"normal","session_id":"s"}"#; + let bytes = format!("{frame}\n").into_bytes(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(bytes))).await; + let mut events = backend.events(); + + let mut confirmed: Option> = None; + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if let SessionEvent::ConfigChanged { mode, .. } = env.event { + confirmed = Some(mode); + return; + } + } + }) + .await; + assert_eq!( + confirmed, + Some(Some("default".to_string())), + "system/status{{permissionMode:normal}} → ConfigChanged{{mode:default}} (normal→default)" + ); + assert_eq!( + backend.capabilities().current_mode.as_deref(), + Some("default"), + "the inbound applied mode becomes the authoritative current_mode" + ); + } + + /// sniff_mode autonomous-exit + dedup: a system/status carrying a NEW mode emits + /// ConfigChanged; a repeated status echoing the SAME mode does NOT (reducer-ignored, + /// but keep the stream clean). Pins the "autonomous plan→bypass exit" path that was + /// dropped before sniff_mode (the bug this fix closes). + #[tokio::test] + async fn sniff_mode_emits_on_autonomous_change_and_dedups_repeats() { + // status[0] plan → status[1] bypassPermissions (autonomous exit) → status[2] + // bypassPermissions again (echo; must NOT re-emit). + let frames = concat!( + r#"{"type":"system","subtype":"status","permissionMode":"plan","session_id":"s"}"#, + "\n", + r#"{"type":"system","subtype":"status","permissionMode":"bypassPermissions","session_id":"s"}"#, + "\n", + r#"{"type":"system","subtype":"status","permissionMode":"bypassPermissions","session_id":"s"}"#, + "\n", + ); + let backend = + ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(frames.as_bytes().to_vec()))) + .await; + let mut events = backend.events(); + + let mut modes: Vec = Vec::new(); + let _ = tokio::time::timeout(std::time::Duration::from_millis(600), async { + while let Some(env) = events.next().await { + if let SessionEvent::ConfigChanged { mode: Some(m), .. } = env.event { + modes.push(m); + } + } + }) + .await; + assert_eq!( + modes, + vec!["plan".to_string(), "bypassPermissions".to_string()], + "two distinct modes emit (incl the autonomous plan→bypass exit); the repeat is deduped" + ); + } + + #[tokio::test] + async fn sniff_set_mode_response_error_clears_override_and_diagnoses() { + // A rejected switch (e.g. bypass without the unlock flag, or as root) replies + // error. The optimistic switch did NOT take → the reader CLEARS the override + // (so the picker shows the actually-enforced mode, not the refused one) and + // surfaces an AdapterSpecific{mode_switch_rejected} diagnostic. + let frame = r#"{"type":"control_response","response":{"subtype":"error","request_id":"ctl-1","error":"Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions"}}"#; + let bytes = format!("{frame}\n").into_bytes(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(bytes))).await; + let mut events = backend.events(); + + let mut diag: Option = None; + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if let SessionEvent::AdapterSpecific { tag, payload } = env.event + && tag == "mode_switch_rejected" + { + diag = payload.get("error").and_then(|e| e.as_str()).map(str::to_string); + return; + } + } + }) + .await; + assert!( + diag.is_some_and(|e| e.contains("permission mode")), + "a permission-mode rejection surfaces an AdapterSpecific{{mode_switch_rejected}}" + ); + assert_eq!( + backend.capabilities().current_mode, + None, + "a rejected switch clears the optimistic override (no lying picker)" + ); + } + + /// #99: a REJECTED `set_config_option(effort)` (claude returns a + /// `control_response{subtype:"error"}` for a bad effort value) must surface a + /// `Notice{Warning}` carrying the label + error, not be silently dropped. Routed + /// strictly by the ctl-id registered in pending_set_config — a permission-mode + /// error (or any other ctl-id) produces NO spurious effort Notice. + #[tokio::test] + async fn sniff_set_config_reject_surfaces_notice_not_silent() { + // A gated tail: the error control_response for our effort set's ctl-id (ctl-9), + // PLUS a permission-mode error for a DIFFERENT id (ctl-1) — the latter must not + // produce an effort Notice (it has no pending_set_config entry). + let tail = concat!( + r#"{"type":"control_response","response":{"subtype":"error","request_id":"ctl-1","error":"Cannot set permission mode to bypassPermissions"}}"#, + "\n", + r#"{"type":"control_response","response":{"subtype":"error","request_id":"ctl-9","error":"unknown effort level: ultra"}}"#, + "\n", + ) + .as_bytes() + .to_vec(); + let fake = FakeAgentIo::never_exits(Vec::new()).with_gated_tail(tail); + let release = fake.stdout_releaser(); + let backend = ClaudeSessionBackend::build_with_io("s-effort-err", Box::new(fake)).await; + // Register the in-flight effort set keyed on the id we minted (live path: + // dispatch(SetConfigOption{effort}) does this). + backend.set_pending_set_config_for_test("ctl-9", "effort\u{2192}ultra"); + + let mut events = backend.events(); + release(); + + let notice = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(env) = events.next().await { + if let SessionEvent::Notice { level, message } = env.event { + return Some((level, message)); + } + } + None + }) + .await + .expect("must not hang") + .expect("a rejected effort set must surface a Notice (not be silently dropped)"); + assert_eq!(notice.0, crate::event::NoticeLevel::Warning); + assert!( + notice.1.contains("effort\u{2192}ultra") && notice.1.contains("unknown effort level: ultra"), + "the Notice carries the label + claude's error message, got: {}", + notice.1 + ); + // The matching pending entry was claimed; the permission-mode error (ctl-1) + // never had one, so it produced no effort Notice and left no leak. + assert!( + backend + .pending_set_config + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_empty(), + "the pending_set_config entry is claimed (no leak)" + ); + } + + /// set_model is OPTIMISTIC (design §9.10.1). LIVE-PROBED (2.1.187, + /// protocols/samples/claude-cli/2.1.187/_all_set_model.jsonl): claude's set_model + /// control_response is a BARE {subtype:"success"} with NO model echo (and a bogus + /// id also returns success), so there is no wire signal to reconcile against — the + /// reader must NOT emit a ConfigChanged from a set_model reply (that would require + /// parsing a shape the wire never sends = inert + self-confirming). The ONLY + /// ConfigChanged{model} comes from the dispatch(SetModel) optimistic emit; the real + /// applied model is read back from the next turn's system/init. This pins that the + /// reader stays silent on a bare set_model ack (the prior inferred-shape reconcile + /// + its two self-confirming tests were removed). + #[tokio::test] + async fn bare_set_model_success_ack_produces_no_reader_side_config_changed() { + // The real wire: a bare success ack with no nested response body. + let frame = r#"{"type":"control_response","response":{"subtype":"success","request_id":"ctl-1"}}"#; + let bytes = format!("{frame}\n").into_bytes(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(bytes))).await; + let mut events = backend.events(); + + let mut saw_config_changed = false; + let _ = tokio::time::timeout(std::time::Duration::from_millis(400), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::ConfigChanged { .. }) { + saw_config_changed = true; + return; + } + } + }) + .await; + assert!( + !saw_config_changed, + "a bare set_model success ack must NOT trigger a reader-side ConfigChanged \ + (set_model is Optimistic — only dispatch emits it; the reader has no wire to reconcile)" + ); + } + + #[tokio::test] + async fn sniff_session_info_get_context_usage_maps_to_session_info() { + // G: claude's get_context_usage reply (keyed by ctl-qsi-usage-N) → + // SessionInfo{context_usage:{used,max,categories}}. Shape pinned from + // samples/claude-cli/2.1.186/get_context_usage_response.json. + let frame = format!( + r#"{{"type":"control_response","response":{{"subtype":"success","request_id":"{QSI_USAGE_PREFIX}3","response":{{"totalTokens":3025,"maxTokens":200000,"categories":[{{"name":"System prompt","tokens":1460}},{{"name":"Skills","tokens":1529}}]}}}}}}"# + ); + let bytes = format!("{frame}\n").into_bytes(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(bytes))).await; + let mut events = backend.events(); + + let mut got: Option = None; + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if let SessionEvent::SessionInfo { + context_usage: Some(u), .. + } = env.event + { + got = Some(u); + return; + } + } + }) + .await; + let u = got.expect("get_context_usage → SessionInfo{context_usage}"); + assert_eq!(u.used, 3025); + assert_eq!(u.max, 200000); + assert_eq!(u.categories.len(), 2); + assert_eq!(u.categories[0].name, "System prompt"); + assert_eq!(u.categories[0].tokens, 1460); + } + + #[tokio::test] + async fn sniff_session_info_get_session_cost_maps_to_session_info() { + // G: claude's get_session_cost reply (keyed by ctl-qsi-cost-N) → + // SessionInfo{cost_text} (a preformatted report; we do not parse it). + let frame = format!( + r#"{{"type":"control_response","response":{{"subtype":"success","request_id":"{QSI_COST_PREFIX}5","response":{{"text":"Total cost: $0.1180"}}}}}}"# + ); + let bytes = format!("{frame}\n").into_bytes(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(bytes))).await; + let mut events = backend.events(); + + let mut got: Option = None; + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if let SessionEvent::SessionInfo { cost_text: Some(t), .. } = env.event { + got = Some(t); + return; + } + } + }) + .await; + assert_eq!(got.as_deref(), Some("Total cost: $0.1180")); + } + + #[tokio::test] + async fn sniff_task_emits_rich_subagent_detail_from_workflow_progress() { + // 009 R6b / H1: a task_progress frame's workflow_progress[] yields a rich + // SubagentDetail per workflow_agent — keyed by agentId (the per-agent id, + // distinct from the container task_id), parent_ref = task_id, carrying + // model/tokens/toolCalls/loop-state/lastToolName for the per-agent panel. + // (Real shape from workflow_multiagent_3parallel_1fail.ndjson 'done' frame.) + let frame = r#"{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu-1","workflow_progress":[{"type":"workflow_phase","index":1,"title":"Run"},{"type":"workflow_agent","index":1,"label":"run:C","agentId":"agent-C","state":"done","model":"opus","tokens":10107,"toolCalls":4,"lastToolName":"StructuredOutput"}]}"#; + let bytes = format!("{frame}\n").into_bytes(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(bytes))).await; + let mut events = backend.events(); + + let mut detail = None; + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if let SessionEvent::SubagentDetail { .. } = &env.event { + detail = Some(env.event); + return; + } + } + }) + .await; + + let SessionEvent::SubagentDetail { + r#ref, + parent_ref, + label, + loop_state, + model, + tokens, + tool_calls, + last_tool_name, + } = detail.expect("a workflow_agent must yield a SubagentDetail") + else { + unreachable!() + }; + assert_eq!( + r#ref, "agent-C", + "ref = agentId (per-agent id, NOT the container task_id)" + ); + assert_eq!( + parent_ref.as_deref(), + Some("wanv3yy20"), + "parent_ref = container task_id (1:N)" + ); + assert_eq!(label.as_deref(), Some("run:C")); + assert_eq!(loop_state, Some(crate::state::WorkflowLoopState::Done)); + assert_eq!(model.as_deref(), Some("opus")); + assert_eq!(tokens, Some(10107)); + assert_eq!(tool_calls, Some(4)); + assert_eq!(last_tool_name.as_deref(), Some("StructuredOutput")); + } + + /// H1 anti-collapse (audit): replay the REAL multi-agent workflow fixture + /// (`workflow_multiagent_3parallel_1fail.ndjson`, 6 parallel Task subagents, + /// one of which fails) and assert the N distinct task_ids surface as N DISTINCT + /// roster refs with INDEPENDENT terminal statuses — they must NOT collapse to a + /// single entry, and the one failure must not be smeared onto the others. + /// The failure signal is on the top-level SubagentUpdate stream (task_id + /// `bgw0rnxcj` → status:failed → Errored), NOT on the workflow_progress + /// SubagentDetail stream (those carry no failure). Pins keyed-by-ref upsert + /// (reducer + orchestrator both key on r#ref). + #[tokio::test] + async fn multiagent_fixture_emits_distinct_subagents_one_errored() { + use crate::event::SubagentStatus; + use std::collections::HashMap; + + let bytes = + include_str!("../../tests/fixtures/claude_2.1.176_workflow_multiagent_3parallel_1fail.ndjson").as_bytes(); + let backend = + ClaudeSessionBackend::build_with_io("s", Box::new(FakeAgentIo::never_exits(bytes.to_vec()))).await; + let mut events = backend.events(); + + // Collect the LAST status seen per task ref (last-write-wins, mirroring the + // reducer's upsert). Drain until the stream goes quiet. + let mut last_status: HashMap = HashMap::new(); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Ok(Some(env)) = tokio::time::timeout(std::time::Duration::from_millis(300), events.next()).await { + if let SessionEvent::SubagentUpdate { r#ref, status, .. } = env.event { + last_status.insert(r#ref, status); + } + } + }) + .await; + + // N distinct refs did NOT collapse (the fixture has 6 parallel tasks). + assert!( + last_status.len() >= 3, + "≥3 distinct subagent refs must survive (no collapse to one row), got {} refs: {:?}", + last_status.len(), + last_status.keys().collect::>() + ); + // Exactly one is Errored, and it is the specific failed task — the failure + // is NOT smeared onto the others. + let errored: Vec<&String> = last_status + .iter() + .filter(|(_, s)| matches!(s, SubagentStatus::Errored)) + .map(|(r, _)| r) + .collect(); + assert_eq!( + errored.len(), + 1, + "exactly one subagent failed (independent statuses), got errored={errored:?}" + ); + assert_eq!(errored[0], "bgw0rnxcj", "the failed ref is the fixture's failed task"); + // At least two others reached Completed independently (not dragged to Errored). + let completed = last_status + .values() + .filter(|s| matches!(s, SubagentStatus::Completed)) + .count(); + assert!( + completed >= 2, + "≥2 sibling subagents complete independently of the one failure, got {completed} completed" + ); + } + + #[tokio::test] + async fn sniff_task_maps_terminal_statuses() { + use crate::event::SubagentStatus; + for (wire, expected) in [ + ("completed", SubagentStatus::Completed), + ("failed", SubagentStatus::Errored), + ("stopped", SubagentStatus::Interrupted), + ] { + let frame = format!(r#"{{"type":"system","subtype":"task_notification","task_id":"t","status":"{wire}"}}"#); + let backend = ClaudeSessionBackend::build_with_io( + "s", + Box::new(FakeAgentIo::never_exits(format!("{frame}\n").into_bytes())), + ) + .await; + let mut events = backend.events(); + let got = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if let SessionEvent::SubagentUpdate { status, .. } = env.event { + return Some(status); + } + } + None + }) + .await + .ok() + .flatten(); + assert_eq!(got, Some(expected), "task_notification status={wire} → {expected:?}"); + } + } +} diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs new file mode 100644 index 000000000..db00632aa --- /dev/null +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -0,0 +1,7065 @@ +//! 007 §C5 (codex variant): `CodexConnection` / `CodexSessionBackend` over +//! `codex app-server --stdio` JSON-RPC. This is the REAL point of feature 007 — +//! the first non-claude backend, proving the seam is genuinely transport- +//! agnostic (a fundamentally different wire: bidirectional JSON-RPC with +//! server-initiated reverse-RPC, vs claude's one-way `--print` pipe). +//! +//! Two freeze-blockers (§C5 A1/A2/A3) are handled BY CONSTRUCTION here: +//! - A1 (ThreadItem closed-enum panic): we NEVER deserialize into codex's +//! closed 16-variant `ThreadItem`. We parse `item` as `serde_json::Value` +//! and match on the `type` string with a fallthrough → `AdapterSpecific`. +//! An unknown future `type` is data, not a panic. +//! - A2/A3 (reverse-RPC deadlock): the reader loop WRITES A REAL JSON-RPC +//! RESPONSE back to stdin for blocking server requests so the channel never +//! deadlocks / hangs the turn. Infra requests (`account/chatgptAuthTokens/ +//! refresh`, `attestation/generate`) get an immediate -32601 error (we hold no +//! ChatGPT tokens — this deployment runs codex on Bedrock — so the honest reply +//! is "unsupported", which unblocks); tool/file approvals are NOT auto-answered +//! — they surface as `Permission` and the conversation's `AnswerPermission` +//! writes the keyed accept/decline response. +//! +//! The reader-task parse helpers (`reader_task`, `map_*`, `handle_reverse_rpc`, +//! `emit`) are now production-reachable via `open_session`'s live spawn (R4) and +//! independently contract-tested via the `build_with_io` seam. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use aionui_process::Spawner; +use serde_json::{Value, json}; +use tokio::sync::{Mutex, broadcast}; + +use super::suspend::{ProcHandle, SuspendController, spawn_idle_timer}; +use super::types::{ + Admission, BackendError, CancelTarget, Command, CommandReceipt, ContentBlock, PendingPermissionView, + SessionEnvelope, SessionSpec, +}; +use super::{BackendConnection, SessionBackend, SessionConfig}; +use crate::adapter::AgentIo; +use crate::capability::{BlockSet, Capabilities, CapabilityTier, CommandSet, PromptAcceptedSource, SignalSet}; +use crate::event::{CancelReason, ProvisioningPhase, SessionEvent, StopReason, SubagentStatus, TurnOutcome}; +use futures_util::stream::{BoxStream, StreamExt}; + +/// Connection-level factory for codex. Holds the injected `Spawner`. Unlike +/// claude (1:1), codex's app-server CAN multiplex threads on one process — but +/// P1 opens one process per logical session (multiplexing is a later refinement; +/// the seam already supports it via the threadId→session_id demux). +pub struct CodexConnection { + /// Injected spawner (S14 — never raw-spawn) used by `open_session` to launch + /// `codex app-server`. + spawner: Arc, +} + +impl CodexConnection { + pub fn new(spawner: Arc) -> Self { + Self { spawner } + } +} + +#[async_trait::async_trait] +impl BackendConnection for CodexConnection { + async fn open_session( + &self, + spec: SessionSpec, + config: SessionConfig, + ) -> Result, BackendError> { + // Live spawn: `codex app-server --stdio` via the INJECTED Spawner (S14, + // never raw-spawn). The PARSE/reverse-RPC/dispatch contract (the real 007 + // risk) is fully hermetic-tested via build_with_io; this path adds the + // process + JSON-RPC handshake (mirrors ClaudeConnection::open_session). + let logical_id = match &spec { + SessionSpec::Fresh { session_id } => session_id.clone(), + SessionSpec::Resume { session_id, .. } => session_id.clone(), + }; + let mut args = vec!["app-server".to_string()]; + args.extend(config.extra_args.iter().cloned()); + let cmd = aionui_common::CommandSpec { + command: "codex".into(), + args, + env: Vec::new(), + cwd: config.cwd.clone(), + }; + let proc = self + .spawner + .spawn(cmd, &[], "aionui-session") + .await + .map_err(|e| BackendError::Transport(format!("codex spawn failed: {e}")))?; + let io: Box = Box::new(crate::adapter::ManagedProcessIo::new(proc)); + // F-4 wake recipe: a Dormant→dispatch wake re-spawns `codex app-server` and + // replays the resume handshake against the bound threadId. Capture the + // spawner + config so it is logically continuous (§4.1). idle_ttl=None + // (default) → never suspends → identical to pre-F-4. + let wake = CodexWakeRecipe { + spawner: Some(self.spawner.clone()), + config: config.clone(), + }; + let mut backend = CodexSessionBackend::spawn_with_wake(logical_id, io, wake, config.idle_ttl_ms).await; + // Seed the current model (M1): the backend tracks it from the start so the + // model selector's current value and a subsequent SetModel are consistent. + // OPTIMISTIC seed: the model is not actually bound at thread/start + // (codex-model-gating). `reconcile_codex_model` clears this back to None if the + // requested model turns out NOT to be in the discovered catalog, so a stale + // picker default never poisons every turn. (Feature 012 removed the old + // collaborationMode dependency: SetMode now uses the permissions channel and + // needs no current_model — this seed is purely for the model axis.) + if let Some(model) = &config.model { + *backend.current_model.lock().await = Some(model.clone()); + } + // GAP-D: also seed the immutable capabilities SNAPSHOT's current_model / + // current_mode from config (parity with claude_conn, which does this at + // spawn). Without this `capabilities().current_model` stays None even with a + // known config.model. (The live `current_model` Mutex above is for building + // collaborationMode; the snapshot is what the conversation reads to render + // the model/mode selector. SetModel/SetMode updates flow via ConfigChanged, + // not by mutating this open-time snapshot — §5.5.) + backend.capabilities.current_model = config.model.clone(); + // Present the current mode in the SAME value space as the catalog (frontend-facing + // legacy bare token). `config.mode` can arrive in ANY accepted vocabulary — the + // #608 canonical id `agent-full-access` (what `normalize_requested_mode` yields + // from a persisted full-access mode on Resume), a legacy alias, or an older + // persisted colon id (`:danger-full-access`) — so it must go through the full + // inbound→outbound round trip (`mode_to_catalog_value`), not the outbound leg + // alone: `profile_id_to_legacy_value` translates only colon ids and would pass + // `agent-full-access` through verbatim, a value the catalog never contains + // ([read-only, auto, full-access]) → the picker highlights nothing and the + // compact pill renders an empty label after "权限 ·". + // + // P9 (fresh-session parity): a fresh thread carries no requested tier + // (`config.mode` None) and `thread/start` launches on codex's workspace-write + // default, so seed the current mode to that tier's value (`:workspace` → `auto`) — + // exactly what the legacy `@zed-industries/codex-acp` path advertised as + // `currentModeId` on a fresh `session/new` (live-verified: `"auto"`, never empty), + // so the picker shows a highlighted default instead of a blank. This is a faithful + // replication of the thread's real launch tier, not a masking default. + // + // The fresh-default is gated to `SessionSpec::Fresh`: on Resume codex restores the + // thread's own tier and surfaces it via `thread/settings/updated`, so falsely + // seeding `auto` for a resumed thread that was actually on another tier would + // mis-highlight until the notification lands (and Resume's fresh currentModeId is + // not live-verified). Resume therefore keeps only the normalized persisted value. + let normalized_config_mode = config.mode.as_deref().map(codex_perm::mode_to_catalog_value); + backend.capabilities.current_mode = match &spec { + SessionSpec::Fresh { .. } => { + normalized_config_mode.or_else(|| Some(codex_perm::profile_id_to_legacy_value(":workspace"))) + } + SessionSpec::Resume { .. } => normalized_config_mode, + }; + + // JSON-RPC handshake over the retained stdin (the reader task is already + // draining stdout). REAL codex 0.137.0 wire (verified against the + // aion-probe transcripts in protocols/verification fixtures): + // initialize{clientInfo} → thread/start{approvalPolicy,sandbox,cwd} + // (Fresh; NO model — applied later via a validated SetModel, see + // reconcile_codex_model) | thread/resume{threadId}. The threadId comes back BOTH in the + // thread/* RESULT and the `thread/started` NOTIFICATION; the reader binds it + // from the latter (two-id, §4.1). For Resume we already hold it, so + // run_handshake pre-seeds the binding so the first `turn/start` has a + // threadId without waiting on the wire. Same wire frames as a wake re-attach + // (run_handshake is shared with wake_handle). + let resume_tid = match &spec { + SessionSpec::Fresh { .. } => None, + // lost backend session → start fresh under the same logical id (§4.1) + SessionSpec::Resume { backend_session_id, .. } => backend_session_id.clone(), + }; + backend.run_handshake(resume_tid.as_deref()).await?; + + // codex-model-gating: `thread/start` intentionally did NOT bind `config.model` + // (see `thread_start_params`), nor a permission tier (`thread/start` carries no + // `permissions` field, U1) — the thread launched on codex's own default model + + // default permission profile. Now apply the requested model+mode the ACP way: + // wait for `model/list` / `permissionProfile/list` to fill the catalogs, then + // dispatch VALIDATED `SetModel`/`SetMode` (each dropped if not in its catalog, so + // a stale picker default never poisons every turn). Detached + off the open hot + // path; only a Fresh session with a requested model and/or mode does any work + // (Resume keeps codex's rollout-restored model + permission tier; no requested + // config → nothing to reconcile). The two are SEQUENCED (model first) only to keep + // the two writes deterministic — SetMode no longer depends on current_model + // (feature 012置换: permissions channel), but sequencing keeps the wire order stable. + if matches!(spec, SessionSpec::Fresh { .. }) && (config.model.is_some() || config.mode.is_some()) { + let backend = Arc::new(backend); + spawn_codex_reconcile(backend.clone(), config.model.clone(), config.mode.clone()); + return Ok(backend); + } + + Ok(Arc::new(backend)) + } + + async fn close_session(&self, _session_id: &str) -> Result<(), BackendError> { + // No connection-level per-session state to release: `open_session` returns + // a self-owned `CodexSessionBackend` (it is not registered in any map on + // `self`), and the conversation layer holds that `Arc`. + // Graceful close therefore happens when the conversation drops its handle — + // `CodexSessionBackend::drop` aborts the reader, which releases the + // `AgentIo` clone so the persistent `codex app-server` is reaped + // (kill_on_drop). There is no codex `thread/close` RPC to send. Idempotent. + Ok(()) + } + + fn capabilities(&self) -> Capabilities { + codex_capabilities() + } +} + +/// How long a post-handshake reconcile waits for a `*/list` response to fill its +/// catalog before giving up. 100 × 50ms = 5s — the same bound `spawn_catalog_writeback` +/// uses to wait for models (codex answers modes before models). If the catalog never +/// arrives we do NOT apply the requested value (we cannot validate it), leaving the +/// thread on codex's launch default rather than risk binding a bad model/mode. +const CODEX_RECONCILE_POLLS: u32 = 100; + +/// codex-model/mode-gating self-heal (the ACP `clear_invalid_desired_*` + +/// `reconcile_session` port). `thread/start` launched the thread on codex's OWN default +/// model+mode (it deliberately did not embed `config.model`, and codex has no +/// `thread/start` mode param at all). This detached task applies the requested model +/// then mode, each validated against its discovered catalog. The two are SEQUENCED — +/// model MUST settle first because `SetMode` builds a `collaborationMode` around the +/// tracked `current_model`; running them concurrently could fire `SetMode` while +/// `current_model` is still the (possibly-invalid) optimistic seed or already cleared. +fn spawn_codex_reconcile(backend: Arc, model: Option, mode: Option) { + tokio::spawn(async move { + if let Some(model) = model { + reconcile_codex_model(&backend, model).await; + } + if let Some(mode) = mode { + reconcile_codex_mode(&backend, mode).await; + } + }); +} + +/// Wait for a codex `*/list` catalog to populate `discovered`, returning the id list. +/// Empty vec = never populated within the poll bound (cannot validate). +async fn await_codex_catalog( + backend: &CodexSessionBackend, + extract: impl Fn(&Discovered) -> Vec, +) -> Vec { + for _ in 0..CODEX_RECONCILE_POLLS { + { + let disc = backend.discovered.lock().unwrap_or_else(|e| e.into_inner()); + let ids = extract(&disc); + if !ids.is_empty() { + return ids; + } + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + Vec::new() +} + +/// Apply `requested` model the ACP way: wait for `model/list` to fill the catalog, then +/// - if `requested` IS in the catalog → dispatch a `SetModel` (validated apply; +/// success converges via `thread/settings/updated`); +/// - if `requested` is NOT in the catalog → DROP it (WARN) and clear the optimistic +/// open-time `current_model` seed, so a stale frontend default (e.g. `gpt-5.5` the +/// local codex lacks) never binds and poisons every turn with an opaque +/// UNKNOWN_UPSTREAM_ERROR. Exact ACP contract (`session/new` carried no model; the +/// model was applied only after `clear_invalid_desired_model` validated it against +/// the session/new catalog), adapted to codex's `model/list`-after-`thread/start` +/// ordering. +async fn reconcile_codex_model(backend: &CodexSessionBackend, requested: String) { + let catalog = await_codex_catalog(backend, |d| d.models.iter().map(|m| m.id.clone()).collect()).await; + + if catalog.is_empty() { + // Never learned the catalog → cannot validate. Leave codex on its launch + // default (the safe choice) rather than bind a possibly-invalid model. + tracing::warn!( + requested_model = %requested, + "codex model reconcile: model/list never populated; leaving thread on codex default \ + (requested model NOT applied — cannot validate)" + ); + return; + } + + if !catalog.contains(&requested) { + // DROP the invalid desire (ACP `clear_invalid_desired_model`). Clear the + // optimistic open-time seed so SetMode can't later build a collaborationMode + // around a model codex rejected, and the UI intent doesn't outlive reality. + tracing::warn!( + requested_model = %requested, + catalog = ?catalog, + "codex model reconcile: requested model not in catalog; dropping it \ + (thread stays on codex default)" + ); + *backend.current_model.lock().await = None; + return; + } + + // Valid → apply via the normal SetModel wire (validated apply). Success converges to + // the UI via the `thread/settings/updated` → ConfigChanged notif; a rejection + // surfaces as a Notice{Warning} (pending_set path). + tracing::info!( + model = %requested, + "codex model reconcile: applying requested model (validated against catalog)" + ); + if let Err(e) = backend.dispatch(Command::SetModel { model: requested }).await { + tracing::error!(error = %e, "codex model reconcile: SetModel dispatch failed"); + } +} + +/// Apply `requested` mode the ACP way, symmetric to [`reconcile_codex_model`]. For codex +/// the mode axis IS the permission axis: a thread always launches on codex's default +/// permission profile (`thread/start` carries NO `permissions` field, U1), so a persisted +/// non-default tier was never applied at open before this. This closes that gap AND +/// validates against the DISCOVERED catalog, exactly as legacy ACP `set_mode` gated on +/// `is_mode_valid` (advertised `availableModes`): wait for `permissionProfile/list` to +/// fill the catalog (colon-prefixed profile ids), normalize a legacy persisted value onto +/// its colon id, then apply a `SetMode` if the value IS in the catalog, or DROP it (WARN, +/// leaving codex's default) if it is NOT. Empty/never-populated catalog → do NOT apply +/// (cannot validate). Unlike the old collaborationMode path this needs no settled +/// `current_model`. +async fn reconcile_codex_mode(backend: &CodexSessionBackend, requested: String) { + // The discovered catalog's `ModeInfo.id` is now the FRONTEND-facing legacy bare token + // (`auto` for the workspace tier); the wire/validation axis speaks colon ids. Normalize + // each catalog id back to its colon profile id so both sides of the `contains` check + // below are colon-shaped (a custom profile is already colon → normalize is a no-op). + let catalog: Vec = await_codex_catalog(backend, |d| { + d.modes + .iter() + .map(|m| codex_perm::normalize_to_profile_id(&m.id)) + .collect() + }) + .await; + + if catalog.is_empty() { + tracing::warn!( + requested_mode = %requested, + "codex mode reconcile: permissionProfile/list never populated; leaving thread on codex default \ + (requested mode NOT applied — cannot validate)" + ); + return; + } + + // Normalize the persisted/legacy value onto its colon-prefixed profile id BEFORE + // validating: a stored legacy `yolo`/`full-access` must resolve to `:danger-full-access` + // (a discovered id) rather than miss the catalog; a already-colon discovered id passes + // through. Then validate against the LIVE catalog — a colon id no longer advertised + // (a removed custom profile) is dropped, mirroring legacy `is_mode_valid`. + let normalized = codex_perm::normalize_to_profile_id(&requested); + if !catalog.contains(&normalized) { + tracing::warn!( + requested_mode = %requested, + normalized_mode = %normalized, + catalog = ?catalog, + "codex mode reconcile: requested mode not in catalog; dropping it \ + (thread stays on codex default)" + ); + return; + } + + tracing::info!( + mode = %normalized, + "codex mode reconcile: applying requested mode (validated against catalog)" + ); + if let Err(e) = backend.dispatch(Command::SetMode { mode: normalized }).await { + // The permissions channel needs no current_model, so a dispatch error here is a + // genuine transport/reject — surface it (still WARN: the thread stays on codex's + // default, which is safe, so this is not a turn-blocking contract violation). + tracing::warn!(error = %e, "codex mode reconcile: SetMode dispatch failed; mode not applied"); + } +} + +/// A pure params object that knows how to wrap itself in a JSON-RPC request frame. +/// Extracted so the handshake wire shapes are unit-testable WITHOUT a live process +/// (open_session's spawn path needs a real codex binary). +struct HandshakeParams(Value); + +impl HandshakeParams { + fn into_frame(self, id: u64, method: &str) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": self.0 }) + } +} + +/// Prefix tagging a `Permission.request_id` that came from an MCP +/// `mcpServer/elicitation/request` (vs a command/file `*/requestApproval`). The +/// two reverse-RPCs need DIFFERENT response bodies — elicitation wants +/// `{action, content}`, approval wants `{decision}` — so `dispatch(AnswerPermission)` +/// branches on this prefix to pick the wire shape. The reducer ref-counts on +/// `kind` only and never inspects the request_id, so this is a transparent +/// dispatch-side discriminator (no new shared state). The conversation layer +/// echoes the request_id back verbatim, so the prefix survives the round-trip. +const ELICIT_PREFIX: &str = "elicit:"; + +/// `initialize` params (REAL codex 0.137.0). ⚠️ `capabilities.experimentalApi:true` +/// is REQUIRED, not optional: the experimental methods we advertise + use +/// (thread/settings/update for SetMode/SetModel, thread/turns/list for +/// ListCheckpoints) are `#[experimental]` on the codex server (common.rs:532/603) +/// and rejected with `invalid_request` unless this is set +/// (message_processor.rs:826-830; codex's own test +/// `thread_settings_update_requires_experimental_api_capability`). The field is +/// NESTED under `capabilities` (InitializeParams schema — a top-level +/// `experimentalApi` is silently ignored), serialized camelCase. +fn initialize_params() -> HandshakeParams { + HandshakeParams(json!({ + "clientInfo": { "name": "aionui-session", "version": "0.1.0" }, + "capabilities": { "experimentalApi": true, "requestAttestation": false } + })) +} + +/// `thread/start` params (Fresh / lost-Resume). approvalPolicy/sandbox are valid +/// AskForApproval/SandboxMode enum values; cwd threaded from config. +/// +/// ⚠️ MODEL IS DELIBERATELY NOT EMBEDDED HERE (codex-model-gating regression fix). +/// `thread/start` binds the model for the WHOLE thread, and `turn/start` carries NO +/// model — so a stale/invalid `config.model` (e.g. a frontend picker default like +/// `gpt-5.5` the local codex doesn't have) bound here makes EVERY turn fail with an +/// opaque UNKNOWN_UPSTREAM_ERROR (live-repro: a fresh codex conv's first reply +/// fails; an old conv resumes via thread/resume which sends no model → works). The +/// catalog (`model/list`) is UNKNOWN at this instant — it is fired AFTER thread/start +/// in `run_handshake` — so we CANNOT validate the model here. Instead we launch on +/// codex's OWN default model (always valid locally) and apply `config.model` later +/// via a VALIDATED `SetModel`, dropping it if it is not in the discovered catalog. +/// This is the faithful port of the ACP path, which likewise launched model-less +/// (`session/new` carried no model) and applied the model only after +/// `clear_invalid_desired_model` validated it against the session/new catalog. +/// +/// Wave 0c: the session-init surface is injected here. codex reads MCP servers +/// from its CONFIG (NOT a per-thread param), so they go into `config.mcp_servers` +/// — a map keyed by name (verified live, 0.139.0: thread/start accepts it AND +/// launches the servers). The preset/system prompt goes into `baseInstructions`. +/// Both are omitted when empty so the pre-0c handshake is byte-identical. +fn thread_start_params(config: &SessionConfig) -> HandshakeParams { + // G1-A: data-drive the sandbox from SessionConfig (None ⇒ workspace-write, + // byte-identical to the pre-G1-A handshake). A yolo agent resolves to + // "danger-full-access" at the orchestration boundary (app registry), restoring + // the legacy codex_sandbox mapping without writing ~/.codex/config.toml. + let sandbox = config.sandbox_mode.as_deref().unwrap_or("workspace-write"); + // approvalPolicy is data-driven (sibling of sandbox): None ⇒ "on-request" + // (byte-identical to the pre-data-driven handshake); a yolo / full-access agent + // resolves to "never" at the orchestration boundary (app registry). + let approval = config.approval_policy.as_deref().unwrap_or("on-request"); + let mut params = json!({ + "approvalPolicy": approval, + "sandbox": sandbox, + }); + if let Some(cwd) = &config.cwd { + params["cwd"] = json!(cwd); + } + // NB: `config.model` is intentionally NOT written here — see the doc comment + // above. It is applied post-discovery by `reconcile_codex_model` (validated). + if !config.init.mcp_servers.is_empty() { + params["config"] = json!({ "mcp_servers": build_codex_mcp_servers(&config.init.mcp_servers) }); + } + if let Some(preset) = &config.init.preset_context { + params["baseInstructions"] = json!(preset); + } + HandshakeParams(params) +} + +/// Serialize neutral [`McpServerSpec`]s into codex's `config.mcp_servers` MAP +/// (keyed by name), the shape codex's config loader expects (verified live + +/// against `codex mcp add` TOML output). DISTINCT from the ACP wire: codex stdio +/// `env` is a MAP `{KEY:VAL}` (not an array of `{name,value}`), and an HTTP server +/// carries `{url, bearer_token_env_var}` (codex resolves the token from the env +/// var; there is no inline-headers field). Pure `serde_json`, no codex SDK. +fn build_codex_mcp_servers(servers: &[crate::backend::McpServerSpec]) -> Value { + use crate::backend::McpTransport; + let mut map = serde_json::Map::new(); + for s in servers { + let entry = match &s.transport { + McpTransport::Stdio { command, args, env } => { + let env_map: serde_json::Map = env.iter().map(|(k, v)| (k.clone(), json!(v))).collect(); + json!({ "command": command, "args": args, "env": Value::Object(env_map) }) + } + // codex streamable-http MCP: url + (optional) bearer token env var. The + // neutral spec carries headers; codex takes a bearer_token_env_var, so we + // pass the url and let codex's own auth/oauth path handle credentials + // (inline arbitrary headers are not a codex config field). + McpTransport::Http { url, .. } | McpTransport::Sse { url, .. } => { + json!({ "url": url }) + } + }; + map.insert(s.name.clone(), entry); + } + Value::Object(map) +} + +/// codex's declared capabilities (§C5.5). tier=Hook (app-server is a parsed +/// JSON-RPC but not the full claude stream); supports the rich command set + +/// answer_auth (auth_methods non-empty → mid-session re-auth path) + rewind (G3: +/// thread/rollback down + Rewound{to_turn} up). +pub fn codex_capabilities() -> Capabilities { + Capabilities { + tier: CapabilityTier::Hook, + emits: SignalSet { + heartbeat: true, + tool_lifecycle: true, + terminal_result: true, + }, + supported_commands: CommandSet { + steer: true, + // codex has NO tool-scoped cancel on the wire (only turn/interrupt = + // whole-turn); dispatch(Cancel{Tool}) returns CommandNotSupported. The + // cap MUST advertise false so a Layer-1 consumer never surfaces a + // cancel-tool affordance the backend always rejects (matches the + // authoritative §C5.5 stub `cancel_tool: false /* P1+ */`). + cancel_tool: false, + answer_permission: true, + answer_auth: true, + acknowledge: true, + set_mode: true, + set_model: true, + // G3: rewind = true. codex's wire (thread/rollback) rewinds; the seam + // now wires the full T17 model — down: thread/rollback{numTurns}; up: + // Rewound{to_turn} receipt (the orchestrator rehydrates to it, the + // conversation forks from it, parent block stream append-only). dispatch + // idle-gates it (mid-turn rollback is rejected). cap=true ↔ dispatch + // accepts (the cap-behavior invariant holds). + rewind: true, + list_checkpoints: true, // thread/turns/list + // codex has a usage notification (thread/tokenUsage/updated) but no + // on-demand cumulative context/cost QUERY wire → false. + query_session_info: false, + }, + prompt_blocks: BlockSet { + text: true, + image: true, + audio: false, + // resource = true: a ResourceLink is delivered by reference as a codex + // `UserInput::Mention { name, path }` (turn.rs:266–297) — codex's native + // @file mention, not a base64 body. The file must be reachable from the + // codex spawn cwd / sandbox roots (same constraint as claude's Read-tool + // path-ref). `accepts_files()` derives from this bit. + resource: true, + at_mention: false, + }, + prompt_accepted: PromptAcceptedSource::Native, // turn/started is a real wire ack + available_models: Vec::new(), + available_modes: Vec::new(), + current_model: None, + current_mode: None, + current_effort: None, + auth_methods: vec!["chatgptAuthTokens".into(), "refresh".into()], + // 009 R2: codex advertises steer, but the conv layer does not route Steer + // today (B5), so there is no proactive next-turn input path → false. (Keying + // can_queue off steer here would be the MX-QUEUE-3 dead button.) Flips true + // only when B5 wires Steer routing. + accepts_proactive_input: false, + // #101: codex has no slash-command wire today — stays empty. + slash_commands: Vec::new(), + } +} + +/// Per-session codex handle. `&self`-concurrent (stdin write behind a Mutex). +pub struct CodexSessionBackend { + session_id: String, + capabilities: Capabilities, + /// JSON-RPC request id counter (outbound client requests). + rpc_id: AtomicU64, + /// Live turn epoch (set on dispatch(Send), read by the reader to stamp). + turn_gen: Arc, + /// stdin shared with the reader task: dispatch writes client requests; the + /// reader writes auto-responses to infra reverse-RPCs (A2/A3 deadlock guard). + /// Both go through the same async Mutex, so writes are serialized. + stdin: Arc>>, + event_tx: broadcast::Sender, + /// F-4 self-suspend controller, owning the live `{reader, io}` pair. The reader + /// is the long-lived JSON-RPC reader: codex's app-server is PERSISTENT (stdout + /// never EOFs), so it would block forever on `next_line()`, pinning its + /// `Arc` clone alive → the child is never reaped. The controller + /// aborts the reader on suspend AND on Drop (`abort_on_drop`, M5), releasing the + /// clone so the subprocess is reaped. When idle_ttl=None (default) the slot + /// stays Active for life — identical to the pre-F-4 behavior. + suspend: Arc, + /// Per-backend idle timer (Some only when idle_ttl is set). Aborted on Drop. + idle_timer: Option>, + /// Everything needed to re-spawn (`thread/resume`) the codex app-server on wake. + wake: CodexWakeRecipe, + /// Shared reader-task inputs, cloned into the open-time reader AND every + /// post-wake reader so they drain into the same event_tx/turn_gen/bindings. + reader_state: CodexReaderState, + /// F-4 turn-active flag (shared with the reader via `reader_state`): set on + /// dispatch(Send), cleared by the reader at the terminal. The idle timer reads + /// it so a streaming turn is never suspended mid-flight. + turn_in_flight: Arc, + /// Logical session_id ← backend threadId binding (filled on thread/started, + /// or pre-seeded on Resume). All `turn/*` + `thread/*` client requests need + /// it. Two-id (§4.1): the backend threadId never escapes upward. + thread_binding: Arc>>, + /// The id of the in-flight turn (codex `turn/started.turn.id`), needed by + /// `turn/interrupt{turnId}` and `turn/steer{expectedTurnId}` (optimistic + /// concurrency token). Set on `turn/started`, cleared on terminal. + active_turn_id: Arc>>, + /// The wire id of a pending `account/chatgptAuthTokens/refresh` reverse-RPC + /// (R6/R15): set by the reader when it surfaces `Permission{Auth}`, consumed + /// by `dispatch(AnswerAuth)` which writes the keyed RESPONSE carrying the + /// supplied tokens. UNLIKE infra reverse-RPCs this is NOT auto-answered — a + /// human/credential source must satisfy it (mid-session re-auth, §6b b3). + pending_auth_id: Arc>>, + /// The current model id (M1): codex's `collaborationMode` for SetMode REQUIRES + /// `settings.model`, so the backend must know it. Seeded from config at open, + /// updated by `dispatch(SetModel)` + the `thread/settings/updated` notif. None + /// until known → SetMode rejects (can't build a valid collaborationMode). + current_model: Arc>>, + /// REST-recovery (`GET /confirmations`) source: the currently-open (unanswered) + /// tool/file/elicitation approval requests, keyed by the SAME request_id the + /// backend surfaced on `SessionEvent::Permission` (so the recovered card's + /// id==call_id matches the live frame and de-dups). The value is a safe title + /// label derived from the reverse-RPC method (NOT the command body — TIO-13). + /// Lifecycle: the reader inserts on each `*/requestApproval` (+ elicitation) + /// reverse-RPC, removes on `serverRequest/resolved` (codex retracted/answered it) + /// and `dispatch(AnswerPermission)` (we answered it). `std::sync::Mutex` because + /// the sync `pending_permission_requests()` trait method reads it without await — + /// mirrors claude's `pending_perms`. Behind an Arc so the reader (cloned into + /// every post-wake reader via `reader_state`) shares the one registry. + pending_tool_approvals: Arc>>, + /// GAP-A: rpc-id → client_msg_id correlation for in-flight `turn/start` + /// requests. codex IS a bidirectional JSON-RPC client: `turn/start` gets a + /// synchronous response `{turn:{id,status:inProgress}}` keyed by the request + /// id — that response IS the "prompt accepted" receipt (NOT the `turn/started` + /// notification). dispatch(Send) inserts (rpc_id → client_msg_id); the reader + /// claims the matching response and emits `PromptAccepted{client_msg_id}` so + /// the conversation's pending queue drains (Addendum 3). Mirrors how any + /// JSON-RPC SDK correlates a reply to its request. + pending_sends: Arc>>, + /// B-CODEX-MODEL-LIST (§9.10 discovery): rpc ids of the `model/list` + + /// `collaborationMode/list` calls `open_session` issues at handshake, mapped to + /// which list they fill. The reader claims the matching responses and writes + /// `discovered`. (We do NOT block the first Send on these — fill is lazy; if a + /// Send races ahead the UI just sees the switcher populate a beat later.) + pending_discovery: Arc>>, + /// Live-discovered models/modes (B-CODEX-MODEL-LIST). `capabilities()` merges + /// these into the returned snapshot. Behind an Arc so the reader can fill it + /// after `open_session` returns (the static `codex_capabilities()` cannot carry + /// per-session discovery). + discovered: Arc>, + /// rpc_id → `"mode→"` / `"model→"` label for in-flight + /// `thread/settings/update` SetMode/SetModel requests. The reader claims the + /// response: a JSON-RPC ERROR (e.g. an invalid model/mode rejected by codex) is + /// surfaced as a `Notice{Warning}` + error log instead of being silently dropped + /// (a failed set the user would never see). A SUCCESS does NOTHING here — codex + /// converges via the separate `thread/settings/updated` notification (handled in + /// `map_notification` → ConfigChanged, live-verified), so emitting here too would + /// duplicate the ConfigChanged. The codex analogue of acp_conn's `pending_set`. + pending_set: Arc>>, +} + +/// Which pending response a claimed rpc id maps to. Models/Modes fill the +/// per-session `discovered` cache (capabilities() merges them); Checkpoints maps +/// to a `CheckpointList` event (O2 up-leg); Rewind maps to a `Rewound{to_turn}` +/// receipt (G3 up-leg — the post-rollback history-end the orchestrator rehydrates +/// to / the conversation forks from, T17). All four are query/command responses +/// the reader claims by rpc id; none touches the FSM. +#[derive(Clone, Copy)] +enum DiscoveryKind { + Models, + /// codex's mode axis: filled from `permissionProfile/list` and mapped to the fixed + /// permission-tier enum (feature 012). codex sends no `collaborationMode/list`. + Permissions, + Checkpoints, + Rewind, +} + +/// Per-session handshake-discovered capability lists (B-CODEX-MODEL-LIST). +#[derive(Default, Clone)] +struct Discovered { + models: Vec, + /// For codex this holds the fixed permission-tier mode enum mapped from + /// `permissionProfile/list` (feature 012), NOT collaborationMode. + modes: Vec, +} + +/// What `CodexSessionBackend::wake_handle` needs to re-spawn the codex app-server +/// after an idle suspend and replay the resume handshake. `inert()` (no spawner) +/// is used for test-built backends, which never suspend, so it is never consulted. +struct CodexWakeRecipe { + spawner: Option>, + config: SessionConfig, +} + +impl CodexWakeRecipe { + /// A recipe that cannot wake (no spawner). Used by `spawn`/`build_with_io` + /// where suspension is never enabled. + #[cfg(any(test, feature = "test-support"))] + fn inert() -> Self { + Self { + spawner: None, + config: SessionConfig::default(), + } + } +} + +/// Shared reader inputs — held by the backend, cloned into the open-time reader +/// and every post-wake reader, so they all drain into the same broadcast/atomics +/// and bindings (the threadId binding survives a suspend, so a wake re-attaches). +#[derive(Clone)] +struct CodexReaderState { + session_id: String, + turn_gen: Arc, + event_tx: broadcast::Sender, + thread_binding: Arc>>, + active_turn_id: Arc>>, + pending_auth_id: Arc>>, + pending_tool_approvals: Arc>>, + pending_sends: Arc>>, + pending_discovery: Arc>>, + pending_set: Arc>>, + discovered: Arc>, + stdin: Arc>>, + /// F-4 turn-active flag: set on dispatch(Send), cleared by the reader at a turn + /// terminal (TurnResult / Detached). The idle timer reads it so a streaming turn + /// is never suspended mid-flight. + turn_in_flight: Arc, +} + +/// Spawn a codex JSON-RPC reader over `stdout`/`io` using the shared state. Used +/// both at open (`spawn`) and on every idle-wake (`wake_handle`). +fn start_codex_reader( + state: &CodexReaderState, + stdout: Option, + io: Arc, +) -> tokio::task::JoinHandle<()> { + let state = state.clone(); + tokio::spawn(async move { + reader_task( + state.session_id, + stdout, + io, + state.turn_gen, + state.event_tx, + state.thread_binding, + state.active_turn_id, + state.pending_auth_id, + state.pending_tool_approvals, + state.pending_sends, + state.pending_discovery, + state.pending_set, + state.discovered, + state.stdin, + state.turn_in_flight, + ) + .await; + }) +} + +/// The idle-check cadence for a ttl: poll at ~ttl/4 (bounded 1s..=30s). Only +/// consulted when idle_ttl is Some (else no timer is spawned). +fn idle_check_interval_ms(idle_ttl_ms: Option) -> u64 { + match idle_ttl_ms { + Some(ttl) => ((ttl / 4).clamp(1_000, 30_000)) as u64, + None => 30_000, + } +} + +impl CodexSessionBackend { + /// Test-support seam: build over an injected `AgentIo` replaying a codex + /// JSON-RPC fixture WITHOUT spawning a real app-server — proves the + /// parse/reverse-RPC/dispatch contract end-to-end. + #[cfg(any(test, feature = "test-support"))] + pub async fn build_with_io(session_id: impl Into, io: Box) -> Self { + Self::spawn(session_id.into(), io).await + } + + /// Test-support seam: build a SUSPENDABLE backend with a caller-supplied + /// `Spawner` (to observe the wake re-spawn) + an `idle_ttl_ms`. Lets a test + /// drive the suspend→wake path: the idle slot suspends, and the next dispatch + /// wakes via the supplied spawner (asserting the `thread/resume` recipe). + #[cfg(any(test, feature = "test-support"))] + pub async fn build_with_io_suspending( + session_id: impl Into, + io: Box, + spawner: Arc, + idle_ttl_ms: i64, + ) -> Self { + let wake = CodexWakeRecipe { + spawner: Some(spawner), + config: SessionConfig::default(), + }; + Self::spawn_with_wake(session_id.into(), io, wake, Some(idle_ttl_ms)).await + } + + /// Test-support seam: pre-bind the backend threadId (the resume anchor the + /// live path binds from `thread/started`). Lets a hermetic wake test drive the + /// suspend→wake path with a known resume anchor. + #[cfg(any(test, feature = "test-support"))] + pub async fn seed_thread_binding_for_test(&self, thread_id: impl Into) { + *self.thread_binding.lock().await = Some(thread_id.into()); + } + + /// Test-support seam: mark a turn in flight WITHOUT a bound active_turn_id — + /// the cancel-before-fold window (dispatch(Send) ran, but the reader has not yet + /// bound the turn id from the async turn/started). Lets a test drive the + /// pending-interrupt path in dispatch(Cancel). + #[cfg(any(test, feature = "test-support"))] + pub fn mark_turn_in_flight_for_test(&self) { + self.turn_in_flight.store(true, Ordering::SeqCst); + } + + /// Test-support seam: bind the active turn id (simulating the reader applying a + /// late turn/started). Paired with `mark_turn_in_flight_for_test` to exercise the + /// pending-interrupt poll resolving mid-wait. + #[cfg(any(test, feature = "test-support"))] + pub async fn bind_active_turn_for_test(&self, turn_id: impl Into) { + *self.active_turn_id.lock().await = Some(turn_id.into()); + } + + /// Test-support seam: register a pending `model/list` discovery id so a test + /// can drive the model/list RESPONSE through the reader (open_session does this + /// after the handshake; `build_with_io` skips the handshake). Lets a test prove + /// the async-discovery → `capabilities()` merge without a real app-server. + #[cfg(any(test, feature = "test-support"))] + pub async fn register_model_discovery_for_test(&self, rpc_id: u64) { + self.pending_discovery + .lock() + .await + .insert(rpc_id, DiscoveryKind::Models); + } + + /// Test-support seam: register a pending `thread/settings/update` + /// (SetMode/SetModel) rpc id + label so a hermetic fixture can replay an error + /// response and assert the reader surfaces a `Notice` (not a silent drop). On the + /// live path `dispatch(SetMode/SetModel)` registers it. + #[cfg(any(test, feature = "test-support"))] + pub async fn set_pending_set_for_test(&self, rpc_id: u64, label: impl Into) { + self.pending_set.lock().await.insert(rpc_id, label.into()); + } + + /// Test-only convenience: spawn an inert (never-suspending, no-spawner) + /// backend. Production opens via `open_session` → `spawn_with_wake` with a real + /// wake recipe; only the `build_with_io` test seam uses this. + #[cfg(any(test, feature = "test-support"))] + async fn spawn(session_id: String, io: Box) -> Self { + Self::spawn_with_wake(session_id, io, CodexWakeRecipe::inert(), None).await + } + + /// Spawn + (optionally) enable F-4 idle self-suspend. `wake` carries what a + /// Dormant→dispatch wake needs (spawner + config); `idle_ttl_ms` None = never + /// suspend (the `spawn` default), Some = run the idle timer. + async fn spawn_with_wake( + session_id: String, + io: Box, + wake: CodexWakeRecipe, + idle_ttl_ms: Option, + ) -> Self { + let io: Arc = Arc::from(io); + let turn_gen = Arc::new(AtomicU64::new(0)); + let thread_binding = Arc::new(Mutex::new(None)); + let active_turn_id = Arc::new(Mutex::new(None)); + let pending_auth_id = Arc::new(Mutex::new(None)); + let pending_tool_approvals = Arc::new(std::sync::Mutex::new(HashMap::new())); + let current_model = Arc::new(Mutex::new(None)); + let pending_sends = Arc::new(Mutex::new(HashMap::new())); + let pending_discovery = Arc::new(Mutex::new(HashMap::new())); + let pending_set = Arc::new(Mutex::new(HashMap::new())); + let discovered = Arc::new(std::sync::Mutex::new(Discovered::default())); + let turn_in_flight = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (event_tx, _) = broadcast::channel(1024); + + let (stdin, stdout) = match io.take_stdio().await { + Some((stdin, stdout)) => (Some(stdin), Some(stdout)), + None => (None, None), + }; + let stdin = Arc::new(Mutex::new(stdin)); + + let reader_state = CodexReaderState { + session_id: session_id.clone(), + turn_gen: turn_gen.clone(), + event_tx: event_tx.clone(), + thread_binding: thread_binding.clone(), + active_turn_id: active_turn_id.clone(), + pending_auth_id: pending_auth_id.clone(), + pending_tool_approvals: pending_tool_approvals.clone(), + pending_sends: pending_sends.clone(), + pending_discovery: pending_discovery.clone(), + pending_set: pending_set.clone(), + discovered: discovered.clone(), + stdin: stdin.clone(), + turn_in_flight: turn_in_flight.clone(), + }; + let reader = start_codex_reader(&reader_state, stdout, io.clone()); + + let suspend = Arc::new(SuspendController::active( + ProcHandle::new(reader, io), + idle_ttl_ms, + aionui_common::now_ms(), + )); + let idle_timer = { + let tif = turn_in_flight.clone(); + // 009 R6 cleanup path 3: emit BackendSuspended on idle-reap → orchestrator + // clears the workflow_roster (a running workflow's task_notification will + // never arrive once the process is reaped). + let etx = event_tx.clone(); + let sid = session_id.clone(); + let tgen = turn_gen.clone(); + spawn_idle_timer( + &suspend, + idle_check_interval_ms(idle_ttl_ms), + aionui_common::now_ms, + move || tif.load(std::sync::atomic::Ordering::SeqCst), + move || { + let _ = etx.send(SessionEnvelope { + session_id: sid.clone(), + turn_gen: tgen.load(std::sync::atomic::Ordering::SeqCst), + event: SessionEvent::BackendSuspended, + }); + }, + ) + }; + + Self { + session_id, + capabilities: codex_capabilities(), + rpc_id: AtomicU64::new(0), + turn_gen, + stdin, + event_tx, + suspend, + idle_timer, + wake, + reader_state, + turn_in_flight, + thread_binding, + active_turn_id, + pending_auth_id, + pending_tool_approvals, + current_model, + pending_sends, + pending_discovery, + pending_set, + discovered, + } + } + + /// Write one JSON-RPC frame (request or response) to stdin as a single line. + async fn write_frame(&self, frame: Value) -> Result<(), BackendError> { + let mut guard = self.stdin.lock().await; + let stdin = guard + .as_mut() + .ok_or_else(|| BackendError::Transport("codex stdin unavailable".into()))?; + let mut line = serde_json::to_vec(&frame).map_err(|e| BackendError::Transport(e.to_string()))?; + line.push(b'\n'); + use tokio::io::AsyncWriteExt; + stdin + .write_all(&line) + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + stdin + .flush() + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + Ok(()) + } + + fn next_rpc_id(&self) -> u64 { + self.rpc_id.fetch_add(1, Ordering::SeqCst) + 1 + } + + /// Resolve the bound backend threadId, waiting briefly for the async + /// `thread/started` notification (Fresh sessions bind it on the wire; Resume + /// pre-seeds it in open_session). Every `turn/*` + `thread/*` client request + /// needs it. Polls up to ~2s before giving up (the handshake is sub-100ms in + /// practice — see the captured transcripts). + async fn bound_thread(&self) -> Result { + // bug-hunt codex-500: the bound-thread window must cover a COLD start, not just + // a warm dev machine. The old 40×50ms=2s was a magic constant that passed every + // live test on a fast box with an already-trusted ~/.codex (where thread/started + // arrives in <2s), but a fresh/untrusted project slows codex init past 2s → + // timeout → opaque 500. Align to the agent-handshake budget the ACP lane uses + // (~15s); env-overridable for genuinely slow environments. On timeout return the + // RETRYABLE HandshakeTimeout (not Transport→500): the agent is still starting. + self.bound_thread_within(super::handshake_budget()).await + } + + /// Inner: poll for the thread binding within `budget` (the public `bound_thread` + /// passes `handshake_budget()`; tests pass a tiny budget to exercise the timeout + /// branch deterministically without a global env override / a 30s wait). + async fn bound_thread_within(&self, budget: std::time::Duration) -> Result { + let polls = (budget.as_millis() / 50).max(1) as u64; + for _ in 0..polls { + if let Some(tid) = self.thread_binding.lock().await.clone() { + return Ok(tid); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + Err(BackendError::HandshakeTimeout(format!( + "codex threadId not bound (thread/started not received within {budget:?})" + ))) + } + + /// Replay the JSON-RPC handshake over the (already-connected) stdin: an + /// `initialize`, then `thread/start` (Fresh / lost-Resume) or `thread/resume` + /// (Resume — pre-seeds the threadId binding), then the model/list + + /// collaborationMode/list discovery calls (registered in `pending_discovery` + /// so the reader fills `discovered`). Shared by `open_session` (the initial + /// open) and `wake_handle` (an idle-wake re-attach), so the wire shape lives in + /// one place. + async fn run_handshake(&self, resume_thread_id: Option<&str>) -> Result<(), BackendError> { + self.write_frame(initialize_params().into_frame(self.next_rpc_id(), "initialize")) + .await?; + match resume_thread_id { + Some(tid) => { + *self.thread_binding.lock().await = Some(tid.to_string()); + self.write_frame(json!({ + "jsonrpc": "2.0", "id": self.next_rpc_id(), "method": "thread/resume", + "params": { "threadId": tid } + })) + .await?; + } + None => { + self.write_frame(thread_start_params(&self.wake.config).into_frame(self.next_rpc_id(), "thread/start")) + .await?; + } + } + // Discovery (B-CODEX-MODEL-LIST): fire-and-forget; the reader claims the + // responses by rpc id and fills `discovered`. + let model_list_id = self.next_rpc_id(); + self.pending_discovery + .lock() + .await + .insert(model_list_id, DiscoveryKind::Models); + self.write_frame(json!({ + "jsonrpc": "2.0", "id": model_list_id, "method": "model/list", + "params": { "includeHidden": false } + })) + .await?; + // feature 012: codex's mode selector IS its permission axis, so we discover + // `permissionProfile/list` (mapped to the fixed mode enum in `fill_discovery`) + // and do NOT send `collaborationMode/list` (plan/default has no UI entry, + // matching legacy ACP). The reader claims the response and fills `disc.modes`. + // Backends without this list (older codex) simply never respond → modes stay + // empty → no frontend mode selector. + let perm_list_id = self.next_rpc_id(); + self.pending_discovery + .lock() + .await + .insert(perm_list_id, DiscoveryKind::Permissions); + self.write_frame(json!({ + "jsonrpc": "2.0", "id": perm_list_id, "method": "permissionProfile/list", "params": {} + })) + .await?; + Ok(()) + } + + /// Wake from Dormant: re-spawn `codex app-server`, re-take its stdio, swap the + /// fresh stdin into the retained slot, start a new reader on the SAME + /// event_tx/turn_gen/bindings, and replay the resume handshake against the + /// bound threadId (the resume anchor that survived the suspend) — so the FSM + /// and subscribers never notice. Only reached when idle_ttl is set AND the slot + /// was suspended (a test backend has no spawner → `inert()` → never enabled). + async fn wake_handle(&self) -> Result { + let spawner = self + .wake + .spawner + .as_ref() + .ok_or_else(|| BackendError::Transport("codex wake: no spawner (suspension not enabled)".into()))?; + let mut args = vec!["app-server".to_string()]; + args.extend(self.wake.config.extra_args.iter().cloned()); + let cmd = aionui_common::CommandSpec { + command: "codex".into(), + args, + env: Vec::new(), + cwd: self.wake.config.cwd.clone(), + }; + let proc = spawner + .spawn(cmd, &[], "aionui-session") + .await + .map_err(|e| BackendError::Transport(format!("codex resume-spawn failed: {e}")))?; + let io: Arc = Arc::from(Box::new(crate::adapter::ManagedProcessIo::new(proc)) as Box); + let (stdin, stdout) = match io.take_stdio().await { + Some((stdin, stdout)) => (Some(stdin), Some(stdout)), + None => (None, None), + }; + *self.stdin.lock().await = stdin; + // The pre-suspend turn id is dead — a fresh process has no active turn yet. + // Clearing it prevents a steer/interrupt right after wake from targeting a + // stale turn id (the reader re-binds active_turn_id on the next turn/started). + *self.active_turn_id.lock().await = None; + let reader = start_codex_reader(&self.reader_state, stdout, io.clone()); + // Replay the handshake against the bound threadId (resume re-attach). On a + // handshake failure, abort the just-started reader so its AgentIo clone + // releases and the freshly-spawned child is reaped (kill_on_drop) — else it + // leaks (the controller never takes ownership of a failed wake's handle). + let resume_tid = self.thread_binding.lock().await.clone(); + if let Err(e) = self.run_handshake(resume_tid.as_deref()).await { + reader.abort(); + return Err(e); + } + Ok(ProcHandle::new(reader, io)) + } +} + +/// The long-lived JSON-RPC reader: each line is a server notification, a +/// response to one of our requests, or a server-initiated request (reverse-RPC). +/// Notifications → SessionEvent (demuxed by threadId→logical id). Reverse-RPC → +/// AUTO-RESPONDED (A2/A3: never deadlock) and, where user-facing, surfaced as +/// Permission. +#[allow(clippy::too_many_arguments)] +async fn reader_task( + session_id: String, + stdout: Option, + io: Arc, + turn_gen: Arc, + event_tx: broadcast::Sender, + thread_binding: Arc>>, + active_turn_id: Arc>>, + pending_auth_id: Arc>>, + pending_tool_approvals: Arc>>, + pending_sends: Arc>>, + pending_discovery: Arc>>, + pending_set: Arc>>, + discovered: Arc>, + stdin: Arc>>, + turn_in_flight: Arc, +) { + use tokio::io::{AsyncBufReadExt, BufReader}; + + let Some(stdout) = stdout else { + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + // Startup double-take guard: stdio was never available, so there is + // no meaningful stderr to attribute — G2 summary stays None. + SessionEvent::Detached { + exit: None, + redacted_summary: None, + }, + ); + return; + }; + + // R8: has the CURRENT turn already produced its single TurnResult? Set by the + // authoritative `turn/completed`; the trailing `status→idle` is then absorbed. + // Reset on `turn/started` so the NEXT turn can terminate once. + let mut terminated = false; + // R8/M3: codex sends `status→idle` BEFORE `turn/completed`. We DEFER on idle + // (set this) and let the authoritative completed produce the rich terminal. If + // the turn somehow ends with idle but no completed (defensive), this is flushed + // as a clean terminal at EOF so the FSM never hangs Running. + let mut idle_pending = false; + + let mut lines = BufReader::new(stdout).lines(); + // Unbounded mid-turn read (AGENTS.md §"出了问题必须查到根因": NO mid-turn + // watchdog/timeout — it masks the real cause AND false-kills a healthy long turn, + // e.g. codex can legitimately go ~55s silent between a finished tool and the + // agentMessage. A wedged turn is ended by user Cancel, per the no-auto-timeout + // design; startup binding is the only thing bounded, via bound_thread_within). + // A REAL fatal signal (error{willRetry:false}) still synthesizes a terminal below. + loop { + match lines.next_line().await { + Ok(Some(line)) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + let Ok(frame): Result = serde_json::from_str(line) else { + // unparseable line → opaque, never panic + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::AdapterSpecific { + tag: "codex_unparseable".into(), + payload: json!({ "raw": line }), + }, + ); + continue; + }; + + // A server-initiated REQUEST has BOTH `method` and `id` + // (reverse-RPC). A notification has `method` but no `id`. A + // response to our request has `id` + (`result`|`error`), no method. + let method = frame.get("method").and_then(Value::as_str); + let has_id = frame.get("id").is_some(); + match (method, has_id) { + (Some(m), true) => { + // reverse-RPC (ServerRequest): infra → auto-reject to prevent + // deadlock (A2/A3); auth-refresh + approvals → surface as + // Permission (NOT auto-answered — a human/credential answers). + handle_reverse_rpc( + m, + &frame, + &session_id, + &turn_gen, + &event_tx, + &pending_auth_id, + &pending_tool_approvals, + &stdin, + ) + .await; + } + (Some(m), false) => { + // server notification → SessionEvent(s) + let cur = turn_gen.load(Ordering::SeqCst); + let params = frame.get("params").unwrap_or(&Value::Null); + if m == "thread/started" { + // bind threadId (backend transport key, kept private). + if let Some(tid) = params.get("thread").and_then(|t| t.get("id")).and_then(Value::as_str) { + *thread_binding.lock().await = Some(tid.to_string()); + // Addendum 9: lower the binding downstream so the + // conversation persists backend_session_id (the + // resume/rewind anchor). This covers fresh + fork + + // resume re-attach (all surface a thread/started). + emit( + &event_tx, + &session_id, + cur, + SessionEvent::BackendBound { + backend_session_id: Some(tid.to_string()), + }, + ); + } + } + if m == "turn/started" { + terminated = false; // a new turn can terminate once (R8 reset) + idle_pending = false; // and a fresh turn has no deferred idle + // Capture the active turn id (optimistic token needed by + // turn/interrupt{turnId} + turn/steer{expectedTurnId}). + if let Some(tid) = params.get("turn").and_then(|t| t.get("id")).and_then(Value::as_str) { + *active_turn_id.lock().await = Some(tid.to_string()); + } + } + // R8 dual-terminal reconcile: codex sends status→idle FIRST + // (deferred), then the authoritative turn/completed produces + // the rich terminal (M3). Exactly ONE TurnResult per turn. + if m == "turn/completed" || m == "thread/status/changed" { + if let Some(ev) = reconcile_terminal(m, params, &mut terminated, &mut idle_pending) { + // Turn ended → clear the active turn id (a stale token + // would make a later steer/interrupt target a dead turn). + *active_turn_id.lock().await = None; + // F-4: turn terminal → clear the turn-active flag so the + // idle timer may suspend the now-idle process. + turn_in_flight.store(false, Ordering::SeqCst); + emit(&event_tx, &session_id, cur, ev); + } + continue; + } + // serverRequest/resolved: codex's confirmation that a + // ServerRequest (approval or auth-refresh) was answered → + // PermissionResolved so the reducer decrements the matching + // counter (R9/R15). We can't know Tool-vs-Auth from this + // bookkeeping notif alone, so resolve against the pending + // auth id if it matches, else default Tool (the common case). + if m == "serverRequest/resolved" { + let req_id = params.get("request_id").or_else(|| params.get("requestId")).cloned(); + let kind = { + let pending = pending_auth_id.lock().await; + match (pending.as_ref(), req_id.as_ref()) { + (Some(p), Some(r)) if p == r => crate::event::PermissionKind::Auth, + _ => crate::event::PermissionKind::Tool, + } + }; + if matches!(kind, crate::event::PermissionKind::Auth) { + *pending_auth_id.lock().await = None; + } + let resolved_id = req_id.map(|v| v.to_string()).unwrap_or_default(); + // Drop the recovered-card entry: codex resolved this approval + // (answered elsewhere or retracted), so it is no longer a + // pending confirmation for REST recovery. The registry keys + // by the surfaced request_id (raw for tool/file approvals, + // ELICIT_PREFIX-tagged for elicitation), matching the id shape + // stored on the requestApproval emit below. + remove_pending_tool_approval(&pending_tool_approvals, &resolved_id); + emit( + &event_tx, + &session_id, + cur, + SessionEvent::PermissionResolved { + request_id: resolved_id, + kind, + }, + ); + continue; + } + // FATAL error terminal (#codex-no-terminal): a codex + // `error{willRetry:false}` is the turn's terminal cause, but + // codex does NOT reliably follow it with `turn/completed` + // (and may instead go silent). Previously we emitted nothing + // here and bet on a completed that might never come → the FSM + // hung Running forever → permanent UI spinner. Now we synthesize + // an is_error terminal so the turn ends; if a real + // `turn/completed` DOES arrive later, `terminated`/I10 absorb it + // (no double terminal). `willRetry:true` is a transient retry → + // still falls through to map_notification → Heartbeat (NOT a + // terminal). See protocols/design/aioncore-codex-turn-no-terminal-hang-prompt.md. + if m == "error" + && params.get("willRetry").and_then(Value::as_bool) != Some(true) + && turn_in_flight.load(Ordering::SeqCst) + { + if !terminated { + terminated = true; + *active_turn_id.lock().await = None; + turn_in_flight.store(false, Ordering::SeqCst); + let message = params + .get("error") + .and_then(|e| e.get("message").and_then(Value::as_str).or_else(|| e.as_str())) + .unwrap_or("codex reported a fatal error") + .to_string(); + tracing::warn!( + conversation_id = %session_id, + turn_gen = cur, + "codex error{{willRetry:false}} → synthesizing is_error terminal (no turn/completed guaranteed)" + ); + emit(&event_tx, &session_id, cur, synth_error_terminal(message)); + } + continue; + } + for ev in map_notification(m, params) { + emit(&event_tx, &session_id, cur, ev); + } + } + _ => { + // A response to one of OUR client requests (id + result/error, + // no method). GAP-A: claim the `turn/start` response — it is + // codex's synchronous "prompt accepted" receipt (carries + // {turn:{id,status:inProgress}}). If its rpc id matches a + // pending Send, emit PromptAccepted{client_msg_id} so the + // conversation's pending queue drains (Addendum 3). A + // JSON-RPC error response for a pending Send is NOT a + // PromptAccepted (the turn never started) — drop the + // correlation without emitting. Other responses (settings/ + // rollback/etc) flow via notifications; diagnostic only. + if let Some(rid) = frame.get("id").and_then(Value::as_u64) { + let client_msg_id = pending_sends.lock().await.remove(&rid); + if let Some(client_msg_id) = client_msg_id + && frame.get("result").is_some() + { + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::PromptAccepted { client_msg_id }, + ); + } + // B-CODEX-MODEL-LIST / O2: claim a discovery response. + // model/list + collaborationMode/list fill the + // `discovered` cache (capabilities() merges them); + // thread/turns/list (Checkpoints) is mapped to a + // CheckpointList event instead (O2 up-leg — a query + // result, not a capability). Lazy; a later page + // (next_cursor) is ignored — first page bounds the N2 + // unbounded-catalog risk (we don't chase the cursor). + let disc_kind = pending_discovery.lock().await.remove(&rid); + if let Some(kind) = disc_kind + && let Some(result) = frame.get("result") + { + match kind { + DiscoveryKind::Models | DiscoveryKind::Permissions => { + fill_discovery(kind, result, &discovered); + // Signal the async catalog arrival so the conversation + // re-projects the model/mode picker (the ACP + // `emit_snapshot_events` analogue). model/list and + // permissionProfile/list are SEPARATE responses; emit a + // full snapshot of whatever `discovered` holds now, so the + // first arrival already lights the picker and later ones + // refine it. Without this the frontend, which read an empty + // `config_options` on open, never re-fetches and the + // selectors stay disabled. (codex's modes come from + // permissionProfile/list — the fixed permission-tier enum.) + let (models, modes) = { + let disc = discovered.lock().unwrap_or_else(|e| e.into_inner()); + (disc.models.clone(), disc.modes.clone()) + }; + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::CatalogUpdated { + models, + modes, + slash_commands: Vec::new(), + }, + ); + } + DiscoveryKind::Checkpoints => { + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::CheckpointList { + entries: map_turns_to_checkpoints(result), + }, + ); + } + DiscoveryKind::Rewind => { + // G3 up-leg: thread/rollback response → Rewound + // {to_turn}. to_turn = the post-rollback history- + // end turn count (result.thread.turns.len(), the + // turns codex re-sends populated only on rollback/ + // resume/fork/read). The orchestrator rehydrates to + // it / the conversation forks from it (T17); the + // reducer ignores it (no FSM phase change). Without + // this receipt the rollback silently mutated codex + // history with no upward signal (GAP-B). + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::Rewound { + to_turn: rollback_to_turn(result), + }, + ); + } + } + } + + // SetMode/SetModel: claim the `thread/settings/update` + // response (dispatch registered rpc_id → "mode→"/"model→"). + // A JSON-RPC ERROR (codex rejected the model/mode) is surfaced + // as a Notice{Warning} + error log so a FAILED set is visible + // instead of being silently dropped (it used to be claimed by no + // one). A SUCCESS does NOTHING here: codex converges via the + // separate `thread/settings/updated` notification (→ ConfigChanged, + // live-verified) — emitting a second ConfigChanged would duplicate. + // On SUCCESS this does nothing: codex converges via the + // separate thread/settings/updated notification (→ ConfigChanged), + // so the claim only matters when the response carries an error. + if let Some(label) = pending_set.lock().await.remove(&rid) + && let Some(err) = frame.get("error") + { + let message = err + .get("message") + .and_then(Value::as_str) + .unwrap_or("set rejected") + .to_string(); + tracing::error!( + conversation_id = %session_id, + set = %label, + "codex thread/settings/update (SetMode/SetModel/effort) rejected by agent: {message}" + ); + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::Notice { + // NoticeLevel has no Error tier; Warning is the + // highest user-facing level (the error-ness is in + // the message + the error! log above). + level: crate::event::NoticeLevel::Warning, + message: format!("{label} failed: {message}"), + }, + ); + } + } + } + } + } + Ok(None) => break, // EOF + Err(_) => break, + } + } + + // F-4: the reader loop ended (process exited / stdout EOF) → the turn (if any) + // is terminal. Clear the turn-active flag so the idle timer is unblocked. + turn_in_flight.store(false, std::sync::atomic::Ordering::SeqCst); + + // M3 defensive flush: the stream ended with a deferred `status→idle` but NO + // authoritative `turn/completed` ever arrived (not observed in real codex, but + // the §C5 R8 contract allows "one may be missing"). Emit a clean terminal so a + // turn that reached idle isn't left hanging Running. + if idle_pending && !terminated { + *active_turn_id.lock().await = None; + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + synth_clean_terminal(), + ); + } + + // Addendum 9: the backend session is gone (process exited / stdout EOF). Lower + // BackendBound{None} so the conversation knows the live binding is dead (the + // turn won't continue on this process). We do NOT clear `thread_binding` itself + // — the threadId is still the resume anchor (conversation persisted it; a later + // Resume re-attaches via thread/resume). This only signals "not live now". + let was_bound = thread_binding.lock().await.is_some(); + if was_bound { + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::BackendBound { + backend_session_id: None, + }, + ); + } + + let exit = io.wait_for_exit().await; + // G2: redact the stderr tail at the backend boundary so a crash carries a + // user-facing reason (allowlisted, ≤240 chars) without leaking raw stderr. + let redacted_summary = crate::adapter::redact_exit_stderr(io.as_ref()).await; + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::Detached { exit, redacted_summary }, + ); +} + +fn emit(tx: &broadcast::Sender, session_id: &str, turn_gen: u64, event: SessionEvent) { + let _ = tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen, + event, + }); +} + +/// feature 012 — codex permission-profile ↔ fixed-mode-enum mapping (SSOT). +/// +/// codex's permission tiers are DISCOVERED, not a fixed AionUi enum — mirroring the +/// legacy ACP mechanism (`manager/acp/session.rs`: `availableModes[]` advertised by the +/// agent, `is_mode_valid` validates against that live list, AionCore defines no values). +/// codex advertises them via `permissionProfile/list`, each identified by a COLON- +/// PREFIXED id (`:workspace` / `:danger-full-access` / `:read-only`, plus any user +/// `[permissions.]` custom profile — the bare form is rejected on the wire, +/// live-verified 0.139.0). We surface those ids VERBATIM as the mode catalog; codex has +/// no separately-exposed collaborationMode selector, so its mode axis IS the permission +/// axis. +/// +/// This module owns the codex mode-value translation, in BOTH directions, so the colon +/// wire id stays an internal/wire detail and the value the FRONTEND sees is byte-identical +/// to what the legacy `@zed-industries/codex-acp` path advertised (live-verified: the +/// bridge's `availableModes[].id` were the BARE tokens `read-only` / `auto` / `full-access`, +/// never colon-prefixed): +/// - inbound (`normalize_to_profile_id`): a persisted/legacy value → colon profile id, +/// the codex analogue of legacy ACP `mode_normalize::normalize_requested_mode`. +/// - outbound (`profile_id_to_legacy_value`): a discovered colon id → the legacy bare +/// token the frontend keys its i18n / picker off, so all 12 locales auto-adapt with no +/// frontend change. It is the exact inverse of `normalize_to_profile_id` over the three +/// built-in tiers; a custom `[permissions.]` profile (which legacy could not +/// express — the bridge hardcoded only the three tiers) has no bare equivalent, so it +/// flows through colon-and-all in BOTH directions (round-trip preserved). +mod codex_perm { + /// Normalize a persisted/legacy mode value into a colon-prefixed permission-profile + /// id, so an upgrading user's stored value (or a legacy alias) maps onto a real codex + /// profile with no fallback to a value codex would reject. This is the codex analogue + /// of legacy ACP `mode_normalize.rs` (alias → native id) + `codex_sandbox.rs` (2-tier + /// bucketing): + /// - already colon-prefixed (`:workspace`, a discovered/custom id) → passed through + /// verbatim (the discovery path already speaks colon ids) + /// - `agent-full-access` (canonical) / `full-access` / `yolo` / `yoloNoSandbox` → `:danger-full-access` + /// - `read-only` → `:read-only` + /// - `default` / `auto` / `autoEdit` / else → `:workspace` + /// + /// The catch-all lands on `:workspace` (the safe workspace-write tier), never a value + /// codex would reject. Validation against the DISCOVERED catalog (a custom id that no + /// longer exists) happens at the call site, exactly as legacy `is_mode_valid` did. + pub(super) fn normalize_to_profile_id(mode: &str) -> String { + let trimmed = mode.trim(); + if let Some(rest) = trimmed.strip_prefix(':') { + // Already a profile id (discovery / custom / re-persisted colon value): + // pass through verbatim, unless it is empty (`":"`) which is nonsense. + if !rest.is_empty() { + return trimmed.to_owned(); + } + } + match trimmed { + // `agent-full-access` is the #608 canonical codex full-access id (migration 021 + + // `normalize_requested_mode`); legacy `full-access` / `yolo` / `yoloNoSandbox` stay + // recognized for pre-021 persisted data. All map onto the danger-full-access profile. + "agent-full-access" | "full-access" | "yolo" | "yoloNoSandbox" => ":danger-full-access".to_owned(), + "read-only" => ":read-only".to_owned(), + _ => ":workspace".to_owned(), + } + } + + /// Map a colon-prefixed permission-profile id back to the legacy bare mode token the + /// FRONTEND expects, so the direct-CLI path presents the SAME value legacy ACP did + /// (`read-only` / `auto` / `full-access`) — the picker's i18n keys off this value, and + /// `agentMode.json` only carries the bare keys, so this is what makes all 12 locales + /// render "Read Only / Default / Full Access" instead of an English fallback on a colon + /// id that misses every key. + /// + /// The three built-in tiers are the EXACT inverse of `normalize_to_profile_id` + /// (`:workspace` ↔ the workspace-write tier legacy advertised as `auto`), so a value + /// round-trips losslessly: outbound colon → bare here, inbound bare → colon there. + /// A custom `[permissions.]` profile has NO legacy bare form (the bridge hardcoded + /// only the three tiers), so it MUST flow through colon-and-all — stripping its colon to + /// `` would send the frontend a value that `normalize_to_profile_id` cannot recover + /// (it would bucket the unknown bare token into the `:workspace` catch-all → wrong tier + /// applied). Passing the colon through keeps the round-trip intact (the frontend renders + /// its `name` via `defaultValue`, and echoes the colon id back unchanged). + pub(super) fn profile_id_to_legacy_value(profile_id: &str) -> String { + match profile_id { + ":read-only" => "read-only".to_owned(), + ":workspace" => "auto".to_owned(), + ":danger-full-access" => "full-access".to_owned(), + other => other.to_owned(), + } + } + + /// Present a requested/persisted mode value in the CATALOG vocabulary (legacy bare + /// token), whichever accepted vocabulary it arrives in: the inbound leg + /// (`normalize_to_profile_id`) buckets canonical/legacy/colon values onto a colon + /// profile id, and the outbound leg (`profile_id_to_legacy_value`) maps that id onto + /// the bare token the catalog rows carry. Composing the two is what makes the + /// `capabilities.current_mode` seed land on a value the picker can highlight — + /// notably `agent-full-access` (the #608 canonical id `normalize_requested_mode` + /// emits for a resumed full-access conversation, which the outbound leg alone would + /// pass through as a token the catalog never contains) → `:danger-full-access` → + /// `full-access`. A custom colon profile id round-trips verbatim (both legs pass it + /// through), and an unknown bare token lands on the workspace tier's `auto` — the + /// same bucketing the SetMode APPLY path uses, so the displayed tier cannot drift + /// from the tier that would be applied. + pub(super) fn mode_to_catalog_value(mode: &str) -> String { + profile_id_to_legacy_value(&normalize_to_profile_id(mode)) + } + + /// Friendly display `(name, description)` for a built-in codex permission profile. + /// + /// codex's `permissionProfile/list` returns the built-in profiles with `description: + /// null` and NO display name (live-verified 0.139.0 — the wire is just + /// `{"id":":read-only","description":null}`), so a verbatim pass-through would surface + /// bare colon ids (`:workspace`) with no tooltip in the picker. The legacy ACP path did + /// NOT do that: the `@zed-industries/codex-acp` bridge enriched each profile into an ACP + /// `SessionMode{id,name,description}` with a human label and a full sentence before + /// advertising it. This table reproduces that display layer so the direct-CLI path + /// matches the old UX — the strings are copied VERBATIM from the bridge binary + /// (`codex-acp` 0.14.0). It is a codex-specific DISPLAY adaptation (the analogue of + /// `codex_sandbox.rs`'s param adaptation), NOT a value-set definition: the id set still + /// comes from codex, and a custom `[permissions.]` profile (unknown here) keeps + /// falling back to whatever codex sends. + /// + /// Id note: legacy advertised the workspace tier as `auto` displayed "Default"; the + /// direct app-server id for the same tier is `:workspace`. Semantics are identical + /// (workspace-write, approval for network / out-of-workspace edits), so we keep the + /// legacy "Default" label + sentence. + pub(super) fn builtin_profile_display(profile_id: &str) -> Option<(&'static str, &'static str)> { + match profile_id { + ":read-only" => Some(( + "Read Only", + "Codex can read files in the current workspace. Approval is required to edit files or access the internet.", + )), + ":workspace" => Some(( + "Default", + "Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. (Identical to Agent mode)", + )), + ":danger-full-access" => Some(( + "Full Access", + "Codex can edit files outside this workspace and access the internet without asking for approval. Exercise caution when using.", + )), + _ => None, + } + } +} + +/// B-CODEX-MODEL-LIST: map a `model/list` / `collaborationMode/list` response +/// `result` into the `discovered` cache. +/// +/// WIRE SHAPE — calibrated to the REAL capture +/// `protocols/samples/codex-cli/0.137.0/appserver-methods/catalog.jsonl` (id:5 +/// model/list, id:7 collaborationMode/list), NOT to a hand-written assumption +/// (README discipline #9 / dimension 25 — the prior `result.models[]` / +/// `result.modes[]` keys were a self-confirming guess that never matched the wire +/// → empty lists → config-options empty → frontend fell back to a hardcoded model +/// name → Bedrock 404): +/// - BOTH lists are under `result.data[]` (NOT `models`/`modes`); a `nextCursor` +/// rides alongside (first page only — we do not chase it, N2 bound). We try +/// `data` first then fall back to `models`/`modes` so a cross-version rename in +/// either direction degrades gracefully rather than silently emptying. +/// - model item: `{id, displayName, description, supportedReasoningEfforts}` where +/// `supportedReasoningEfforts` is an array of OBJECTS `{reasoningEffort, description}` +/// (the old code read bare strings → every object dropped → empty efforts). We +/// accept both: an object → its `reasoningEffort`, a bare string → itself. +/// - mode item: `{name:"Plan", mode:"plan", model?, reasoning_effort?}`. The id MUST +/// be the lowercase `mode` token, because `dispatch(SetMode)` sends +/// `collaborationMode.mode` = that token (codex rejects the display `name`); `name` +/// is the human label. Falls back to `name` only if `mode` is absent. +/// +/// A genuinely empty list after a successful response means the wire shape drifted +/// again — `warn!` so it is diagnosable (it must never silently degrade to empty +/// like the original bug did). +fn fill_discovery(kind: DiscoveryKind, result: &Value, discovered: &Arc>) { + use crate::capability::{ModeInfo, ModelInfo}; + // The real wire wraps both lists in `data`; `models`/`modes` is the legacy/guessed + // key kept only as a cross-version fallback. + let list = |primary: &str, legacy: &str| -> Option> { + result + .get(primary) + .or_else(|| result.get(legacy)) + .and_then(Value::as_array) + .cloned() + }; + match kind { + DiscoveryKind::Models => { + let arr = list("data", "models"); + let present = arr.is_some(); + let models = arr + .map(|arr| { + arr.iter() + .filter_map(|m| { + let id = m.get("id").and_then(Value::as_str)?.to_string(); + Some(ModelInfo { + id, + name: m.get("displayName").and_then(Value::as_str).unwrap_or("").to_string(), + description: m.get("description").and_then(Value::as_str).map(str::to_string), + reasoning_efforts: m + .get("supportedReasoningEfforts") + .and_then(Value::as_array) + .map(|e| { + e.iter() + // real wire: object {reasoningEffort, description}; + // legacy/guess: bare string. Accept either. + .filter_map(|v| { + v.get("reasoningEffort") + .and_then(Value::as_str) + .or_else(|| v.as_str()) + .map(str::to_string) + }) + .collect() + }) + .unwrap_or_default(), + }) + }) + .collect::>() + }) + .unwrap_or_default(); + if present && models.is_empty() { + tracing::warn!("codex model/list parsed to empty (wire shape may have drifted from result.data[])"); + } + discovered.lock().unwrap_or_else(|e| e.into_inner()).models = models; + } + DiscoveryKind::Permissions => { + // codex's mode axis IS the permission axis. This is the DISCOVERY half of the + // legacy-ACP mechanism (`session.rs::apply_advertised_modes`): every profile + // codex advertises via `permissionProfile/list` is surfaced VERBATIM as a mode + // — colon-prefixed id and all (`:workspace` / `:danger-full-access` / + // `:read-only`, plus any user `[permissions.]` custom profile). We no + // longer translate to a fixed AionUi enum or drop custom profiles: codex + // defines the value set, AionCore only transports it (parity with legacy ACP, + // where `availableModes[]` came straight off the wire). `disc.modes` is the + // SAME cache slot `reconcile_codex_mode` validates against and the capabilities + // snapshot exposes; `collaborationMode/list` is not sent (plan/default has no + // UI entry, matching legacy ACP). + let arr = list("data", "permissions"); + let present = arr.is_some(); + let modes = arr + .map(|arr| { + arr.iter() + .filter_map(|p| { + // Wire id retains the leading colon (`:workspace`) — that is + // what SetMode sends back and what the reader matches. Skip only + // a malformed entry with no id. + let profile_id = p.get("id").and_then(Value::as_str)?; + // Display layer (matches legacy ACP): codex's built-in profiles + // arrive with no name and `description:null`, so prefer codex's + // own fields when present (a custom `[permissions.]` may carry + // them), then the built-in friendly table (verbatim bridge copy), + // then the bare id as a last resort. + let builtin = codex_perm::builtin_profile_display(profile_id); + let name = p + .get("name") + .or_else(|| p.get("displayName")) + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| builtin.map(|(n, _)| n.to_string())) + .unwrap_or_else(|| profile_id.to_string()); + let description = p + .get("description") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| builtin.map(|(_, d)| d.to_string())); + // The catalog's `id` is the value the FRONTEND sees and keys its + // i18n / picker off. Present the legacy bare token + // (`:workspace`→`auto`) so all 12 locales auto-adapt exactly as + // they did on the legacy ACP path; a custom profile keeps its + // colon (no bare equivalent). SetMode's `normalize_to_profile_id` + // is the inverse on the return trip, and `reconcile_codex_mode` + // re-normalizes before validating against the colon wire catalog. + Some(ModeInfo { + id: codex_perm::profile_id_to_legacy_value(profile_id), + name, + description, + }) + }) + .collect::>() + }) + .unwrap_or_default(); + if present && modes.is_empty() { + tracing::warn!("codex permissionProfile/list parsed to empty (no profile with an id)"); + } + discovered.lock().unwrap_or_else(|e| e.into_inner()).modes = modes; + } + // Checkpoints → CheckpointList event, Rewind → Rewound event: both mapped at + // the call site, not a cache fill — fill_discovery is never called for them. + DiscoveryKind::Checkpoints | DiscoveryKind::Rewind => {} + } +} + +/// O2 up-leg: map a `thread/turns/list` response `result` into the +/// `CheckpointList` entries. codex `ThreadTurnsListResponse{data: Vec, ...}` +/// (source-verified thread.rs:1204-1214); each `Turn{id, status, completed_at, ..}` +/// (thread_data.rs:152-174). We surface `Turn.id` as the checkpoint id and the +/// turn `status` as the label (the user-facing "which point"); codex turns have no +/// `turn_gen` (that is our adapter-owned epoch), so `turn_gen` is None. First page +/// only — `next_cursor` is not chased (bounds the N2 unbounded-history risk). +fn map_turns_to_checkpoints(result: &Value) -> Vec { + result + .get("data") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|t| { + let id = t.get("id").and_then(Value::as_str)?.to_string(); + let label = t.get("status").and_then(Value::as_str).map(str::to_string); + Some(crate::event::CheckpointEntry { + id, + label, + turn_gen: None, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// G3 up-leg: derive `Rewound.to_turn` from a `thread/rollback` response. The key +/// path is `result.thread.turns[]` — CONFIRMED against the real wire (live capture +/// `protocols/samples/codex-cli/0.139.0/_all_rollback_plan.jsonl`: the success +/// result is `{thread:{...,turns:[...]}, model, ...}`). `to_turn` = the post-rollback +/// turn count. +/// +/// ⚠️ LIVE-OBSERVED CAVEAT (codex 0.139.0): after a valid `numTurns:1` rollback the +/// returned `thread.turns` was an EMPTY array, so this yields `to_turn = 0` even when +/// history survived. `to_turn` is a **consumer/display signal only** — the reducer's +/// `Rewound` arm is a no-op (reducer.rs:388, never reads the value), so an inaccurate +/// count is cosmetic (UI "rewound to N"), not a state/FSM bug. A count-based +/// history-end is unreliable on this codex version; treat `to_turn` as best-effort. +/// The flat `{turns}` fallback is kept for cross-version tolerance (the real wire +/// nests under `thread`). Missing/empty → 0. +fn rollback_to_turn(result: &Value) -> u64 { + result + .get("thread") + .and_then(|t| t.get("turns")) + .or_else(|| result.get("turns")) + .and_then(Value::as_array) + .map(|a| a.len() as u64) + .unwrap_or(0) +} + +/// Remove a resolved/answered approval from the recovery registry. `serverRequest/ +/// resolved` carries the RAW wire id, but an elicitation was stored under the +/// `ELICIT_PREFIX`-tagged key — so try the raw id first, then the prefixed form, so +/// a resolved notification clears either entry shape. `dispatch(AnswerPermission)` +/// passes the exact stored key (raw or prefixed), which the first lookup catches. +fn remove_pending_tool_approval(pending: &Arc>>, request_id: &str) { + let mut map = pending.lock().unwrap_or_else(|e| e.into_inner()); + if map.remove(request_id).is_none() { + map.remove(&format!("{ELICIT_PREFIX}{request_id}")); + } +} + +/// Reverse-RPC handler (A2/A3). The blocking ServerRequest MUST eventually get a +/// JSON-RPC RESPONSE (same `id`) or the channel deadlocks and the turn hangs. +/// THREE classes: +/// - Pure infra (`attestation/generate`): we cannot satisfy it and no human can +/// either → auto-reject with -32601 NOW so the turn never deadlocks. +/// - Mid-session auth refresh (`account/chatgptAuthTokens/refresh`, R6/R15): a +/// human/credential source CAN satisfy it → surface `Permission{Auth}`, stash +/// the wire id in `pending_auth_id`, and let `dispatch(AnswerAuth)` write the +/// keyed response with the supplied tokens. NOT auto-answered. +/// - Tool/file approvals (`*/requestApproval`): a human decides → `Permission` +/// (Tool); `dispatch(AnswerPermission)` writes the keyed accept/decline. +#[allow(clippy::too_many_arguments)] +async fn handle_reverse_rpc( + method: &str, + frame: &Value, + session_id: &str, + turn_gen: &Arc, + event_tx: &broadcast::Sender, + pending_auth_id: &Arc>>, + pending_tool_approvals: &Arc>>, + stdin: &Arc>>, +) { + let cur = turn_gen.load(Ordering::SeqCst); + let id = frame.get("id").cloned().unwrap_or(Value::Null); + match method { + // Mid-session re-auth (R6/R15): the server hit a 401 mid-turn and is asking + // the client for fresh ChatGPT tokens. A human/credential source answers + // this → surface Permission{Auth} (sets waiting_on_auth) and remember the + // wire id so dispatch(AnswerAuth) can write the keyed response. We do NOT + // auto-answer: that is the whole point of the mid-session-auth path. + "account/chatgptAuthTokens/refresh" => { + *pending_auth_id.lock().await = Some(id.clone()); + emit( + event_tx, + session_id, + cur, + SessionEvent::Permission { + request_id: id.to_string(), + kind: crate::event::PermissionKind::Auth, + // G3 auto-approval is ACP-only (acp_conn parses MCP context); a + // codex auth refresh carries no team-MCP server to allowlist. + metadata: None, + // AskUserQuestion projection is claude-direct only. + tool_name: None, + input: None, + }, + ); + } + // Pure infra (A2/A3): no human can satisfy attestation either, so reply + // with a JSON-RPC -32601 NOW to UNBLOCK the channel. If codex genuinely + // needed it the turn surfaces as a failure (TurnResult.is_error) — strictly + // better than a deadlock. A diagnostic records the auto-answer. + "attestation/generate" => { + write_reverse_error(stdin, &id, -32601, "client cannot supply codex attestation").await; + emit( + event_tx, + session_id, + cur, + SessionEvent::AdapterSpecific { + tag: "codex_reverse_rpc_auto_answered".into(), + payload: json!({ "method": method, "id": id }), + }, + ); + } + // Command/file approval requests → user-facing Permission (Tool). These two + // take a `{decision: accept|decline}` response (CommandExecution/FileChange + // RequestApprovalResponse, schema-verified), which is EXACTLY what + // dispatch(AnswerPermission) writes. The wire `id` is the request_id the + // conversation answers; a human decides (NOT auto-answered here). + // + // ⚠️ We deliberately do NOT surface `item/permissions/requestApproval` here + // (M2): its response is `{permissions: GrantedPermissionProfile, scope}`, + // NOT `{decision}` — answering it with our generic decision body would be + // rejected. Until a permission-grant command exists, it falls through to the + // clean -32601 reject below (unblocks the channel; the escalation just + // can't be granted — strictly better than a malformed answer or a deadlock). + "item/commandExecution/requestApproval" | "item/fileChange/requestApproval" => { + let request_id = id.to_string(); + // Register for REST recovery (`GET /confirmations`): a tool/file approval + // raised before the client subscribed (or after a page reload) must be + // rebuildable, else the turn hangs waiting for an answer that can never be + // given. Keyed by the SAME request_id we surface, so a duplicate live+ + // recovered pair de-dups; the value is a safe title (the approval class, + // NOT the command body — TIO-13). Cleared on serverRequest/resolved or + // dispatch(AnswerPermission). + let title = if method == "item/fileChange/requestApproval" { + "FileChange" + } else { + "CommandExecution" + }; + pending_tool_approvals + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(request_id.clone(), title.to_string()); + emit( + event_tx, + session_id, + cur, + SessionEvent::Permission { + request_id, + kind: crate::event::PermissionKind::Tool, + // G3 auto-approval is ACP-only (acp_conn). The native codex + // app-server command/file approval is not a team-MCP path. + metadata: None, + // AskUserQuestion projection is claude-direct only. + tool_name: None, + input: None, + }, + ); + } + // MCP elicitation (LIVE-confirmed 0.139.0, missing-wire-probe): codex bridges + // both an MCP tool-call APPROVAL and a real MCP server form `elicitation/create` + // to this ONE reverse-RPC, distinguished by `mode` + `_meta.codex_approval_kind` + // + whether `requestedSchema.properties` is empty. BOTH take a + // `{action: "accept"|"decline", content: {...}}` response (NOT `{decision}`), + // so we tag the surfaced request_id with the `ELICIT_PREFIX` and + // dispatch(AnswerPermission) writes the right body shape. A human decides + // (NOT auto-answered) → Permission{Tool} (waiting_on_approval). The reducer + // ref-counts on `kind` only (never the request_id string), so the prefix is + // a transparent dispatch-side discriminator. `serverRequest/resolved` already + // emits the matching PermissionResolved{Tool} on answer. + "mcpServer/elicitation/request" => { + let request_id = format!("{ELICIT_PREFIX}{id}"); + // Carry the elicitation context so the conversation can render a form / + // approval prompt: the message, the requested schema, and the mode. + let input = json!({ + "message": frame.get("params").and_then(|p| p.get("message")), + "requestedSchema": frame.get("params").and_then(|p| p.get("requestedSchema")), + "mode": frame.get("params").and_then(|p| p.get("mode")), + "serverName": frame.get("params").and_then(|p| p.get("serverName")), + }); + // Register for REST recovery, keyed by the ELICIT_PREFIX-tagged request_id + // (the same id we surface + dispatch(AnswerPermission) answers). Title is + // the safe approval class, not the elicitation message body (TIO-13). + pending_tool_approvals + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(request_id.clone(), "Elicitation".to_string()); + emit( + event_tx, + session_id, + cur, + SessionEvent::Permission { + request_id, + kind: crate::event::PermissionKind::Tool, + metadata: None, + tool_name: None, + input: Some(input), + }, + ); + } + // Any other reverse-RPC → unblock with a -32601 error (never let an unknown + // blocking request hang the turn) + opaque diagnostic. This matches the + // ACP-audit finding: an unhandled reverse method should be clean-rejected, + // not silently dropped (which deadlocks a blocking request). + // + // KNOWN deferred case: `item/tool/requestUserInput` (codex's native + // ask-the-user tool, the AskUserQuestion analog) currently falls here and is + // rejected, so the user never sees the question and the tool gets the empty + // fallback. Wiring it (surface Permission{questions} + answer + // {answers:{:{answers}}} via ELICIT_PREFIX, claude AskUserQuestion is the + // template) is a reachable FOLLOW-UP — gap-reaudit confirmed the schema is fully + // defined. NOT wired yet because we could not capture a LIVE requestUserInput + // frame: it is mode-gated (`available_modes`, spec_plan.rs:729) and codex + // refused to invoke it in default OR plan mode in this env ("unavailable in the + // current mode"). Per the no-parser-for-an-unprobed-shape discipline, deferred + // until the trigger/mode is found and a real frame is captured. + _ => { + write_reverse_error(stdin, &id, -32601, "method not handled by aionui-session").await; + emit( + event_tx, + session_id, + cur, + SessionEvent::AdapterSpecific { + tag: "codex_reverse_rpc".into(), + payload: json!({ "method": method, "id": id }), + }, + ); + } + } +} + +/// Write a JSON-RPC ERROR response (`{jsonrpc, id, error{code,message}}`) to the +/// shared stdin. Used by the reader to auto-reject blocking infra reverse-RPCs +/// (A2/A3) so the JSON-RPC channel never deadlocks. Best-effort: a closed stdin +/// means the process is gone and the turn is ending anyway. +async fn write_reverse_error( + stdin: &Arc>>, + id: &Value, + code: i64, + message: &str, +) { + if id.is_null() { + return; // a notification, not a request — nothing to answer + } + let frame = json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }); + let mut guard = stdin.lock().await; + let Some(w) = guard.as_mut() else { return }; + use tokio::io::AsyncWriteExt; + if let Ok(mut line) = serde_json::to_vec(&frame) { + line.push(b'\n'); + let _ = w.write_all(&line).await; + let _ = w.flush().await; + } +} + +/// Map a codex server notification → canonical SessionEvent(s). The A1 fix lives +/// here: `item` payloads are matched on the `type` STRING (never deserialized +/// into the closed 16-variant ThreadItem enum), so an unknown future variant is +/// data (→ AdapterSpecific), not a panic. +fn map_notification(method: &str, params: &Value) -> Vec { + match method { + "turn/started" => vec![], // optimistic; the orchestrator already lowered TurnStarted + "item/agentMessage/delta" => { + let item_id = params.get("itemId").and_then(Value::as_str).unwrap_or("").to_string(); + let text = params.get("delta").and_then(Value::as_str).unwrap_or("").to_string(); + vec![SessionEvent::MessageDelta { item_id, text }] + } + "item/reasoning/textDelta" | "item/reasoning/summaryTextDelta" => { + let item_id = params.get("itemId").and_then(Value::as_str).unwrap_or("").to_string(); + let text = params.get("delta").and_then(Value::as_str).unwrap_or("").to_string(); + vec![SessionEvent::ThoughtDelta { item_id, text }] + } + "item/started" | "item/completed" => map_item(params, method == "item/completed"), + // Live tool-output stream (codex `item/commandExecution/outputDelta`): the + // incremental stdout of a RUNNING command, keyed by the owning tool's itemId. + // PLAINTEXT (NOT base64 — the turn-scoped item stream; verified live 0.139.0). + // → ToolOutputDelta (display liveness; the full output still rides the + // completed item's aggregatedOutput → ToolResult). + "item/commandExecution/outputDelta" => { + let item_id = params.get("itemId").and_then(Value::as_str).unwrap_or("").to_string(); + let text = params.get("delta").and_then(Value::as_str).unwrap_or("").to_string(); + vec![SessionEvent::ToolOutputDelta { item_id, text }] + } + // Live cumulative turn diff (codex `turn/diff/updated`): the FULL git-style + // unified diff of all file edits in the turn so far (full-replace snapshot, + // re-sent as edits land; verified live 0.139.0). → TurnDiffUpdated (display + // liveness; the per-file authoritative diff rides the completed fileChange + // item → ToolResult FilePath). + "turn/diff/updated" => { + let diff = params.get("diff").and_then(Value::as_str).unwrap_or("").to_string(); + vec![SessionEvent::TurnDiffUpdated { diff }] + } + "thread/tokenUsage/updated" => map_usage(params), + // LC-8a: codex to-do plan snapshot. `TurnPlanUpdatedNotification{plan:[{step, + // status}], explanation?}` (schema-verified, codex 0.137.0) → SessionEvent::Plan. + // step→content; camelCase `inProgress`→InProgress; codex has no per-step priority. + "turn/plan/updated" => map_plan(params), + // R8 dual-terminal reconcile: codex signals turn end via BOTH + // `turn/completed` (carries the rich Turn{status,error}) AND + // `thread/status/changed → idle`. They can arrive in EITHER order (or one + // may be missing). We must produce EXACTLY ONE TurnResult per turn. The + // reconcile lives in the reader loop via a single per-turn `terminated` + // flag (NOT turnId dedup — codex does not guarantee matching turnIds across + // the two signals); map_notification just classifies — see reconcile_terminal. + "turn/completed" | "thread/status/changed" => { + // handled by reconcile_terminal in the caller (needs per-turn state) + vec![] + } + // thread/settings/updated → ConfigChanged (frozen C6 §6): the + // non-optimistic confirmation that a SetMode/SetModel applied. The + // conversation updates its mode/model selector on THIS, not on the + // sent-assume-done dispatch return. + // + // feature 012: for codex the mode axis IS the permission axis. The confirmation + // carries `threadSettings.activePermissionProfile.id` = the colon-prefixed profile + // id (LIVE 0.139.0: `:workspace`/`:read-only`/`:danger-full-access`, plus any + // custom `[permissions.]`), which we surface VERBATIM as the current mode — + // the SAME colon id `permissionProfile/list` advertised, so the selector matches a + // discovered entry (legacy-ACP parity: `current_mode_update.currentModeId` was the + // advertised id, untranslated). `activePermissionProfile` is `null` when the tier + // was set via the raw sandboxPolicy channel (not our path) — then we carry no mode + // (leaving the last-known selection). We deliberately do NOT read + // `collaborationMode.mode` (plan/default): codex has no separately-exposed + // collaboration selector in AionUi, and that field would clobber the permission mode. + "thread/settings/updated" => { + let settings = params + .get("threadSettings") + .or_else(|| params.get("thread_settings")) + .unwrap_or(&Value::Null); + let model = settings.get("model").and_then(Value::as_str).map(str::to_string); + let mode = settings + .get("activePermissionProfile") + .and_then(|p| p.get("id")) + .and_then(Value::as_str) + // Map the colon wire id back to the legacy bare token the catalog/frontend + // uses (`:workspace`→`auto`), so the picker highlights the matching entry; + // a custom profile keeps its colon (matches its verbatim catalog entry). + .map(codex_perm::profile_id_to_legacy_value); + vec![SessionEvent::ConfigChanged { mode, model }] + } + // (No `item/userMessage/delta` arm: codex never emits that method — the user + // echo arrives as an `item/*` with item.type=="userMessage", handled by the + // item path. A dead arm here was a guessed method, removed per the protocol + // audit; an unknown method now falls through to the AdapterSpecific catch-all.) + // MCP server startup → Provisioning, mapped per status (parity with claude's + // sniff_init mcp_servers[] → ToolsReady/LoadFailed/Degraded). The wire method + // is `mcpServer/startupStatus/updated` (LIVE-confirmed 0.139.0: starting→failed + // observed) with status ∈ {starting,ready,failed,cancelled}; `error` carries + // the failure reason. Previously this arm matched the WRONG prefix + // `mcpServerStatus` (that is only the OUTBOUND `mcpServerStatus/list` request + // we send — never an inbound notification) and hardcoded ToolsWaiting, so a + // real startup notification fell through to AdapterSpecific and produced NO + // Provisioning, and a failed/cancelled server could never surface as + // Degraded/LoadFailed. Fixed to the real method + a 4-way status map. + "mcpServer/startupStatus/updated" => { + let reason = params + .get("error") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_default(); + let phase = match params.get("status").and_then(Value::as_str).unwrap_or("") { + "starting" => ProvisioningPhase::ToolsWaiting, + "ready" => ProvisioningPhase::ToolsReady, + "failed" => ProvisioningPhase::LoadFailed { reason }, + "cancelled" => ProvisioningPhase::Degraded { reason }, + // Unknown future status → conservative non-terminal (never panic). + _ => ProvisioningPhase::ToolsWaiting, + }; + vec![SessionEvent::Provisioning { phase }] + } + // MCP OAuth completion: success=false means the server is up but unauthorized + // → Degraded (mirrors claude's needs-auth → Degraded). success=true carries no + // FSM signal here (a subsequent startupStatus→ready covers readiness). + "mcpServer/oauthLogin/completed" => { + if params.get("success").and_then(Value::as_bool) == Some(false) { + let reason = params + .get("error") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_default(); + vec![SessionEvent::Provisioning { + phase: ProvisioningPhase::Degraded { reason }, + }] + } else { + vec![] + } + } + // codex `error` notification: `{error: TurnError, threadId, turnId, willRetry}` + // (schema-verified 0.137.0). willRetry=true = a TRANSIENT retry (codex hit an + // error mid-turn and is retrying → a liveness signal, mirrors claude's + // system/api_retry → Heartbeat). willRetry=false (FATAL) is handled in the + // reader loop BEFORE this fn (synthesizes an is_error terminal so the turn + // ends even if codex never sends turn/completed) — it never reaches this arm. + // The `vec![]` is a defensive fallthrough only. + "error" => { + if params.get("willRetry").and_then(Value::as_bool) == Some(true) { + vec![SessionEvent::Heartbeat] + } else { + vec![] + } + } + // Out-of-turn advisories → Notice (was dropped to AdapterSpecific, never seen). + // warning/guardianWarning carry `{message}`; deprecationNotice/configWarning + // carry `{summary, details?}`. guardian/warning/config = Warning; deprecation = + // Info (advisory, non-urgent). Shapes schema-verified (ServerNotification.json). + "warning" | "guardianWarning" => { + let message = params.get("message").and_then(Value::as_str).unwrap_or("").to_string(); + vec![SessionEvent::Notice { + level: crate::event::NoticeLevel::Warning, + message, + }] + } + "configWarning" => { + let message = notice_message(params); + vec![SessionEvent::Notice { + level: crate::event::NoticeLevel::Warning, + message, + }] + } + "deprecationNotice" => { + let message = notice_message(params); + vec![SessionEvent::Notice { + level: crate::event::NoticeLevel::Info, + message, + }] + } + // `hook/*` provisioning is a separate concern (not MCP startup) — kept as a + // coarse ToolsWaiting signal pending its own wire investigation. + m if m.starts_with("hook/") => { + vec![SessionEvent::Provisioning { + phase: ProvisioningPhase::ToolsWaiting, + }] + } + // Unknown notification → opaque (never panic, never drop silently). + _ => vec![SessionEvent::AdapterSpecific { + tag: format!("codex_notif:{method}"), + payload: params.clone(), + }], + } +} + +/// Build a [`SessionEvent::Notice`] message from a codex `deprecationNotice` / +/// `configWarning` params object: `{summary, details?, ...}`. Joins summary + +/// details (when present) so the user sees the actionable guidance, not just the +/// headline. Falls back to `message` then empty string. +fn notice_message(params: &Value) -> String { + let summary = params + .get("summary") + .or_else(|| params.get("message")) + .and_then(Value::as_str) + .unwrap_or(""); + match params.get("details").and_then(Value::as_str) { + Some(d) if !d.is_empty() => format!("{summary} — {d}"), + _ => summary.to_string(), + } +} + +/// Map codex's 7-state `CollabAgentStatus` (schema-full/ServerNotification.json +/// `CollabAgentStatus`) onto our 6-state [`SubagentStatus`]. `notFound` has no +/// roster meaning of its own (the agent is gone) → fold to `Shutdown` so the +/// entry settles terminal and prunes at the boundary (we never invent a 7th +/// state, §9.12 "codex 7 minus NotFound"). Unknown future strings → `Running` +/// (active, non-terminal) so a new codex status never wedges as terminal. +fn map_collab_status(s: &str) -> SubagentStatus { + match s { + "pendingInit" => SubagentStatus::PendingInit, + "running" => SubagentStatus::Running, + "interrupted" => SubagentStatus::Interrupted, + "completed" => SubagentStatus::Completed, + "errored" => SubagentStatus::Errored, + "shutdown" | "notFound" => SubagentStatus::Shutdown, + _ => SubagentStatus::Running, + } +} + +/// A1 CORE: match the item's `type` STRING with a fallthrough. The closed codex +/// ThreadItem enum is NEVER constructed in our code, so an unknown future `type` +/// becomes `AdapterSpecific` instead of a deserialization panic. +fn map_item(params: &Value, completed: bool) -> Vec { + let item = params.get("item").unwrap_or(&Value::Null); + let item_type = item.get("type").and_then(Value::as_str).unwrap_or(""); + let id = item.get("id").and_then(Value::as_str).unwrap_or("").to_string(); + let mut out: Vec = Vec::new(); + + // GAP-E (C5.3 frozen item-brackets, §9.2): emit the ADDITIVE partial-lifecycle + // bracket (Tier-0; reducer no-op) around the content event(s), for the + // bracketable item types the (P2) TurnFinalizer needs. We do NOT bracket the + // server's userMessage echo (not a model item) nor the collabAgent item (a + // subagent lifecycle carried by SubagentUpdate, not a message/tool item). + let bracketed = matches!( + item_type, + "agentMessage" + | "reasoning" + | "commandExecution" + | "mcpToolCall" + | "dynamicToolCall" + | "fileChange" + | "webSearch" + | "imageGeneration" + ); + if bracketed && !completed { + out.push(SessionEvent::ItemStarted { + item_id: id.clone(), + kind: item_kind_for(item_type), + }); + } + + match item_type { + "agentMessage" => { + // the final text already streamed via deltas; the bracket is the signal. + } + "commandExecution" | "mcpToolCall" | "dynamicToolCall" | "fileChange" | "webSearch" | "imageGeneration" => { + if completed { + // 009 R7/H3: a codex tool is failed when status==failed OR a command + // exited non-zero — carry it so a failed tool is not shown as success. + let is_error = item.get("status").and_then(Value::as_str) == Some("failed") + || item.get("exitCode").and_then(Value::as_i64).is_some_and(|c| c != 0); + // 009 R8: carry the tool OUTPUT. `aggregatedOutput` (command stdout) + // is fixture-confirmed (commandExecution completed). codex writes any + // generated file to disk (never inlines bytes); a `fileChange` item + // carries the produced-file PATHs + per-file unified diff in + // `changes[]` (LIVE-confirmed 0.139.0, missing-wire-probe: each entry + // is `{path, kind:{type:"update"|...}, diff}`). We map each change to a + // `FilePath` so the conversation TurnFinalizer renders a FileDiff card + // (turn_finalizer `tool_result_display`) instead of dropping the path. + let mut content = Vec::new(); + if item_type == "fileChange" + && let Some(changes) = item.get("changes").and_then(Value::as_array) + { + for ch in changes { + let Some(path) = ch.get("path").and_then(Value::as_str) else { + continue; + }; + // codex `kind.type` ∈ {add, update, delete, ...}; the unified + // hunk (when present) rides `diff`. We carry it as `new_text` + // so the FileDiff card has a body; `old_text` stays None + // (codex sends a single combined hunk, not before/after pair). + let diff = ch.get("diff").and_then(Value::as_str).map(str::to_string); + content.push(crate::event::ToolResultContent::FilePath { + path: path.to_string(), + mime: None, + old_text: None, + new_text: diff, + }); + } + } + // imageGeneration writes the produced image to disk and reports its + // path in `savedPath` (source-verified: v2/item.rs:372-380 + // ImageGeneration{result:String(base64), saved_path:Option} + // → wire key `savedPath`). Previously DROPPED — we only read + // aggregatedOutput (a commandExecution-only field imageGeneration lacks), + // so the image card was empty. Carry the path as FilePath (NOT the + // base64 `result` as Text — that would dump megabytes of bytes). + if item_type == "imageGeneration" + && let Some(path) = item.get("savedPath").and_then(Value::as_str) + { + content.push(crate::event::ToolResultContent::FilePath { + path: path.to_string(), + mime: None, + old_text: None, + new_text: None, + }); + } + if let Some(text) = item.get("aggregatedOutput").and_then(Value::as_str) + && !text.is_empty() + { + content.push(crate::event::ToolResultContent::Text(text.to_string())); + } + // MCP + dynamic-tool OUTPUT (previously DROPPED — only aggregatedOutput, + // a commandExecution-only field, was read, so every MCP/dynamic tool + // rendered as an empty card = silent data loss). Source-verified shapes + // (openai/codex app-server-protocol v2/item.rs:299/313, mcp.rs:125): + // mcpToolCall completed → result:{content:[mcp Content blocks], + // structuredContent?, _meta?} + error:{message} on failure. + // dynamicToolCall completed → contentItems:[{type:inputText,text}| + // {type:inputImage,imageUrl}]. + content.extend(parse_codex_mcp_result(item.get("result"))); + content.extend(parse_codex_dynamic_content_items(item.get("contentItems"))); + if let Some(msg) = item + .get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + // a failed mcpToolCall carries its cause in error.message (no + // aggregatedOutput) — surface it so the red card has a reason. + content.push(crate::event::ToolResultContent::Text(msg.to_string())); + } + out.push(SessionEvent::ToolResult { + tool_use_id: id.clone(), + is_error, + content, + // 009 H5: codex inline tool item — main agent (collab-agent + // subagents arrive on the separate collabAgentToolCall plane). + parent_tool_use_id: None, + }); + } else { + out.push(SessionEvent::ToolCall { + tool_use_id: id.clone(), + name: item_type.to_string(), + subagent: crate::event::SubagentKind::Inline, + // Gap #4 / H2: carry the codex tool ARGUMENTS. On the started + // (non-completed) item the invocation fields (command/cwd/ + // commandActions for commandExecution; arguments for mcp/dynamic + // tool calls) are present while the output fields are still null, + // so the whole item Value is the faithful argument carrier. + // TIO-13: never logged at info. + input: item.clone(), + // 009 H5: codex inline tool — main agent. + parent_tool_use_id: None, + }); + } + } + "collabAgentToolCall" => { + // §6b b1: a collab/spawned subagent → SubagentUpdate, keyed by the CHILD + // thread id (codex `agentId`, state.rs:80). codex carries the live child + // roster in `agentsStates: { threadId -> { status, message } }` and the + // spawning parent in `senderThreadId`. Emit ONE update per known child so + // the roster reflects every collab agent with its REAL lifecycle status + // (the 7-state `CollabAgentStatus`, mapped to our 6-state SubagentStatus) + // — not a coarse completed-bool, and with the spawn edge (`parent_ref`) + // so multi-level collab renders (reducer.rs:382-392). + let parent_ref = item + .get("senderThreadId") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string); + // `model` is the agent's model id; empty on the spawn-in-flight frame. + let label = item + .get("model") + .and_then(Value::as_str) + .filter(|m| !m.is_empty()) + .map(str::to_string); + match item.get("agentsStates").and_then(Value::as_object) { + Some(states) if !states.is_empty() => { + for (thread_id, st) in states { + let status = st + .get("status") + .and_then(Value::as_str) + .map(map_collab_status) + // agentsStates entry without a status → fall back to the + // bool so it is never stuck mid-lifecycle. + .unwrap_or(if completed { + SubagentStatus::Completed + } else { + SubagentStatus::Running + }); + out.push(SessionEvent::SubagentUpdate { + r#ref: thread_id.clone(), + label: label.clone(), + status, + parent_ref: parent_ref.clone(), + }); + } + } + // No child thread reported yet (the spawn-in-flight frame carries an + // empty `agentsStates`): fall back to the tool-call id so the action + // still surfaces, with a bool-derived status. The spawn tool's own + // completion is terminal, so this fallback entry settles + prunes at + // the turn boundary; the next frame carries the real child threadId. + _ => { + out.push(SessionEvent::SubagentUpdate { + r#ref: id.clone(), + label, + status: if completed { + SubagentStatus::Completed + } else { + SubagentStatus::Running + }, + parent_ref, + }); + } + } + } + "reasoning" => {} // streamed via reasoning/*Delta; bracket is the signal + // The server's echo of the user's own input — NOT a model output. Drop so + // the conversation doesn't duplicate the prompt it already rendered. + "userMessage" => {} + // ⭐ A1: ANY unknown item type (incl. a future codex variant) → opaque, + // NEVER a panic. This is the freeze-blocker fix made structural. + other => out.push(SessionEvent::AdapterSpecific { + tag: format!("codex_item:{other}"), + payload: item.clone(), + }), + } + + if bracketed && completed { + out.push(SessionEvent::ItemCompleted { + item_id: id, + truncation: None, + }); + } + out +} + +/// Map a codex item `type` → the canonical `ItemKind` for the partial-lifecycle +/// bracket (GAP-E). +fn item_kind_for(item_type: &str) -> crate::event::ItemKind { + use crate::event::ItemKind; + match item_type { + "agentMessage" => ItemKind::Text, + "reasoning" => ItemKind::Thinking, + "imageGeneration" => ItemKind::Image, + _ => ItemKind::Tool, + } +} + +/// thread/tokenUsage/updated → UsageDelta. codex gives BOTH total (cumulative) +/// and last (per-turn); we use `.last` directly (G6: native per-turn, no +/// subtraction, no double-count on reconnect — measured 2026-06-10). +fn map_usage(params: &Value) -> Vec { + let usage = params.get("tokenUsage").unwrap_or(&Value::Null); + let last = usage.get("last").unwrap_or(&Value::Null); + let g = |k: &str| last.get(k).and_then(Value::as_u64).unwrap_or(0); + vec![SessionEvent::UsageDelta { + input_tokens: g("inputTokens"), + output_tokens: g("outputTokens"), + total_tokens: g("totalTokens"), + cost_usd: None, + }] +} + +/// LC-8a: codex `turn/plan/updated` params → `SessionEvent::Plan`. `plan` is an +/// array of `{step, status}` (TurnPlanStep); codex carries no per-step priority, so +/// `priority: None`. `explanation` is codex-only (Option). Anti-panic: filter_map +/// over the array, never deserialize a closed enum (A1 doctrine). +fn map_plan(params: &Value) -> Vec { + let entries: Vec = params + .get("plan") + .and_then(Value::as_array) + .map(|steps| { + steps + .iter() + .filter_map(|s| { + let content = s.get("step").and_then(Value::as_str)?.to_string(); + let status = map_plan_status(s.get("status").and_then(Value::as_str).unwrap_or("")); + Some(crate::event::PlanEntry { + content, + status, + priority: None, + }) + }) + .collect() + }) + .unwrap_or_default(); + let explanation = params.get("explanation").and_then(Value::as_str).map(str::to_string); + vec![SessionEvent::Plan { entries, explanation }] +} + +/// Normalize a plan-step status string → canonical `PlanStatus` (I8). Accepts BOTH +/// codex camelCase (`inProgress`) and ACP snake_case (`in_progress`); unknown → +/// `Pending` (never panic). Shared intent with the ACP plan parse. +fn map_plan_status(s: &str) -> crate::event::PlanStatus { + use crate::event::PlanStatus; + match s { + "inProgress" | "in_progress" => PlanStatus::InProgress, + "completed" => PlanStatus::Completed, + _ => PlanStatus::Pending, + } +} + +/// R8 dual-terminal reconcile. codex ends a turn via BOTH `turn/completed` (rich +/// Turn{status,error}) AND `thread/status/changed → idle`, in EITHER order (or +/// one may be absent). We must produce EXACTLY ONE `TurnResult` per turn. +/// +/// codex does NOT guarantee the two signals carry matching (or any) turnId, so +/// matching on turnId is unreliable. Instead we track a single `terminated` +/// flag for the CURRENT turn (the reader processes one turn's frames before the +/// next turn's `turn/started`/deltas, so "current turn" is unambiguous in-stream): +/// - whichever terminal arrives FIRST closes the turn (sets `terminated`); +/// - the SECOND (the other terminal source) is absorbed (`terminated` already set); +/// - a fresh non-terminal turn signal (a new turn/started, or any item event) +/// RESETS `terminated` so the NEXT turn can terminate once. (Reset is driven by +/// the reader on `turn/started`; here we only flip on terminals.) +/// +/// ⚠️ ORDERING (verified against ALL 6 real 0.137.0 transcripts): codex ALWAYS +/// sends `thread/status/changed→idle` BEFORE the authoritative `turn/completed` +/// (which carries status interrupted/failed + httpStatusCode). So `turn/completed` +/// MUST win even though it arrives SECOND — otherwise a failed turn is silently +/// reported `is_error:false` (→ Idle, not Error) and an interrupted turn loses its +/// Cancelled outcome. We therefore DEFER on `idle` (set `idle_pending`, emit +/// nothing) and let `turn/completed` produce the rich terminal. The defensive +/// "idle but no completed ever" case (not observed in real codex, but the §C5 R8 +/// contract says "one may be missing") is flushed as a clean terminal at EOF (see +/// the reader's `flush_pending_terminal`), so the FSM never hangs Running. +/// `idle_pending` resets per turn on `turn/started`. Returns Some only for the +/// authoritative `turn/completed`; `idle` returns None (deferred). +fn reconcile_terminal( + method: &str, + params: &Value, + terminated: &mut bool, + idle_pending: &mut bool, +) -> Option { + match method { + "turn/completed" => { + if *terminated { + return None; // already closed this turn + } + *terminated = true; + *idle_pending = false; // the authoritative terminal supersedes the deferred idle + Some(map_turn_completed(params)) + } + "thread/status/changed" => { + let status = params + .get("status") + .and_then(|s| s.get("type").or(Some(s))) + .and_then(Value::as_str) + .unwrap_or(""); + match status { + "idle" => { + if *terminated { + return None; // turn/completed already produced the terminal + } + // DEFER: do NOT emit a generic terminal here — wait for the + // authoritative turn/completed (carries interrupted/failed status). + *idle_pending = true; + None + } + // systemError is a FATAL session fault (ThreadStatus::SystemError, + // v2/thread.rs). codex does NOT reliably follow it with a turn/completed + // (protocol audit MED) — so treat it as a terminal here instead of + // advisory, else the FSM hangs Running until process EOF. Synthesize an + // is_error terminal; a later turn/completed is absorbed (terminated + + // I10). NOT a watchdog — this is a response to a REAL fault signal. + "systemError" => { + if *terminated { + return None; + } + *terminated = true; + *idle_pending = false; + Some(synth_error_terminal("codex reported a system error".into())) + } + // active / other → advisory (no terminal). + _ => None, + } + } + _ => None, + } +} + +/// A clean fallback terminal (`EndTurn`, `is_error:false`) — used only at EOF when +/// a turn produced `idle` but NO `turn/completed` ever arrived (defensive; codex +/// always sends completed). Reducer routes `is_error:false` → Idle so the FSM +/// never hangs Running. +fn synth_clean_terminal() -> SessionEvent { + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: String::new(), + epoch: 0, + outcome: TurnOutcome::default(), + } +} + +/// A synthetic ERROR terminal (`is_error:true`, `Failed`) used when codex leaves a +/// turn with NO authoritative `turn/completed` — either a fatal `error{willRetry:false}` +/// that codex does not follow with a completed, or a liveness-watchdog timeout (codex +/// went silent mid-turn). Reducer routes `is_error:true` → Error so the FSM leaves +/// Running and the composer unlocks instead of spinning forever. `epoch:0` is restamped +/// by the orchestrator from the live turn_gen; a later real `turn/completed` for the +/// same turn is idempotently absorbed by the reducer's terminal-absorbing law (I10). +fn synth_error_terminal(message: String) -> SessionEvent { + SessionEvent::TurnResult { + is_error: true, + api_error_status: None, + result_text: message, + epoch: 0, + outcome: TurnOutcome::Failed, + } +} + +/// turn/completed → TurnResult, mapping turn.status → outcome (§C2/O3): +/// Parse a completed `mcpToolCall` item's `result.content[]` (MCP/rmcp Content +/// blocks: `{type:"text",text}` / `{type:"image",data:,mimeType}`) into +/// `ToolResultContent`. Previously DROPPED entirely (only aggregatedOutput was read). +/// `structuredContent` (when present) is appended as pretty JSON text so a +/// structured-only tool still shows its payload. None/absent → empty. +fn parse_codex_mcp_result(result: Option<&Value>) -> Vec { + use crate::event::ToolResultContent; + use base64::Engine as _; + let Some(result) = result else { return Vec::new() }; + let mut out = Vec::new(); + if let Some(arr) = result.get("content").and_then(Value::as_array) { + for el in arr { + match el.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(t) = el.get("text").and_then(Value::as_str) { + out.push(ToolResultContent::Text(t.to_string())); + } + } + Some("image") => { + // MCP image block = {type:image, data:, mimeType}. + let media_type = el + .get("mimeType") + .and_then(Value::as_str) + .unwrap_or("image/png") + .to_string(); + if let Some(bytes) = el + .get("data") + .and_then(Value::as_str) + .and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok()) + { + out.push(ToolResultContent::Image { + media_type, + data: bytes, + }); + } + } + _ => {} // resource / audio / unknown — skipped (no neutral mapping yet) + } + } + } + if let Some(sc) = result.get("structuredContent").filter(|v| !v.is_null()) + && let Ok(s) = serde_json::to_string_pretty(sc) + { + out.push(ToolResultContent::Text(s)); + } + out +} + +/// Parse a completed `dynamicToolCall` item's `contentItems[]` +/// (`{type:"inputText",text}` / `{type:"inputImage",imageUrl}`). Previously DROPPED. +/// An `inputImage` is a URL (often a data: or remote URL) — carried as a text +/// reference (we can't assume it is decodable raw bytes). None/absent → empty. +fn parse_codex_dynamic_content_items(items: Option<&Value>) -> Vec { + use crate::event::ToolResultContent; + let Some(arr) = items.and_then(Value::as_array) else { + return Vec::new(); + }; + arr.iter() + .filter_map(|el| match el.get("type").and_then(Value::as_str) { + Some("inputText") => el + .get("text") + .and_then(Value::as_str) + .map(|t| ToolResultContent::Text(t.to_string())), + Some("inputImage") => el + .get("imageUrl") + .and_then(Value::as_str) + .map(|u| ToolResultContent::Text(format!("[image: {u}]"))), + _ => None, + }) + .collect() +} + +/// Completed→EndTurn (or Truncated if a stop reason says so), Interrupted→ +/// Cancelled, Failed→Failed. `is_error` (the reducer's routing bit) is true only +/// for Failed. +fn map_turn_completed(params: &Value) -> SessionEvent { + let turn = params.get("turn").unwrap_or(&Value::Null); + let status = turn.get("status").and_then(Value::as_str).unwrap_or("completed"); + let (is_error, outcome) = match status { + "interrupted" => ( + false, + TurnOutcome::Cancelled { + reason: CancelReason::UserCancel, + }, + ), + "failed" => (true, TurnOutcome::Failed), + // "completed" (or any other treated as clean) → EndTurn. + // + // NOTE (protocol audit): the codex `Turn` struct has NO `stopReason` field + // (source-verified: v2/thread_data.rs Turn + TurnStatus enum; grep stopReason + // across v2/ = 0 hits). A prior maxTokens/maxTurns branch reading turn.stopReason + // was dead code (the key never exists) → removed. codex surfaces truncation as + // a FAILED turn carrying turn.error (e.g. contextWindowExceeded), already routed + // via the "failed" arm above (is_error:true → TurnOutcome::Failed), not as a + // clean Truncated outcome. If a truncation BADGE is wanted later, classify + // turn.error.codexErrorInfo (contextWindowExceeded/usageLimitExceeded) — but + // that is an additive enhancement, not the dead stopReason read. + _ => ( + false, + TurnOutcome::Completed { + stop_reason: StopReason::EndTurn, + }, + ), + }; + let error = turn.get("error"); + let result_text = error + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + // C-3 (correctness): httpStatusCode is NOT top-level on turn.error — it is + // NESTED inside the externally-tagged `CodexErrorInfo` variant object on + // `error.codexErrorInfo`, e.g. {"httpConnectionFailed":{"httpStatusCode":500}} + // (verified-from-source v2/shared.rs:64-112). Reading it at the top level made + // api_error_status ALWAYS None on real failed turns. Walk into codexErrorInfo's + // single variant object; the variants without a status (ServerOverloaded, + // Unauthorized, …) yield None, which is correct. + let api_error_status = error + .and_then(|e| e.get("codexErrorInfo")) + .and_then(Value::as_object) + .and_then(|m| m.values().next()) // the single externally-tagged variant payload + .and_then(|v| v.get("httpStatusCode")) + .and_then(Value::as_u64) + // Fallback: tolerate a top-level httpStatusCode too (defensive — some + // shapes/older versions may flatten it), so we never regress to None when + // the status IS present somewhere. + .or_else(|| error.and_then(|e| e.get("httpStatusCode")).and_then(Value::as_u64)) + // LIVE-found fallback: a real failed turn often carries NO structured + // httpStatusCode — the variant collapses to `codexErrorInfo:"other"` (a + // bare string) and the status lives ONLY in the message, e.g. + // "unexpected status 404 Not Found: The model '…' does not exist". + // (Observed live, codex 0.139.0, bad-model turn.) Parse it out so a real + // provider HTTP error still surfaces its status, mirroring how + // send_error folds status-as-text. Structured paths above win when present. + .or_else(|| extract_http_status_from_message(&result_text)) + .map(|n| n as u16); + SessionEvent::TurnResult { + is_error, + api_error_status, + result_text, + epoch: 0, // orchestrator restamps from the envelope turn_gen (§5.4) + outcome, + } +} + +/// Extract an HTTP status from a codex error message of the form +/// `"unexpected status : …"` (the shape a real provider HTTP error +/// takes when codex collapses `codexErrorInfo` to `"other"` and carries the status +/// only in text — LIVE-observed 0.139.0). Anchored on the literal `"status "` +/// prefix and a 3-digit 1xx–5xx code so it does NOT mis-fire on arbitrary numbers +/// in the message (model ids, request ids, token counts). Returns `None` when the +/// shape is absent — the structured paths remain authoritative. +fn extract_http_status_from_message(message: &str) -> Option { + let lower = message.to_ascii_lowercase(); + let after = lower.split("status ").nth(1)?; + let digits: String = after.chars().take_while(char::is_ascii_digit).collect(); + let code: u64 = digits.parse().ok()?; + (100..=599).contains(&code).then_some(code) +} + +#[async_trait::async_trait] +impl SessionBackend for CodexSessionBackend { + async fn dispatch(&self, command: Command) -> Result { + match command { + Command::Send { content, metadata } => { + // §C6 Layer-2: reject any block kind codex does not advertise + // (prompt_blocks: text + image) BEFORE wire-write — never silently + // drop it ("adapter authoritatively rejects → CommandNotSupported, never a silent drop"). An + // audio/resource/at_mention block is rejected, keyed on its + // `content_block:` name. + let blocks = self.capabilities().prompt_blocks; + if let Some(bad) = content.iter().find(|b| !blocks.allows(b)) { + return Err(BackendError::CommandNotSupported { + command: crate::capability::block_kind_name(bad), + }); + } + // 009 R1c: a flight-period Send (a turn is already active) must NOT + // open a second turn_gen. codex's app-server merges an overlapping + // input into the live turn under a SINGLE turnId (verified: + // concurrent_turn_start_merge.jsonl) — issuing a second `turn/start` + // + fetch_add would phantom-split one wire turn across two turn_gen + // buckets downstream (GROUP BY turn_gen). Mirror the Cancel arm's + // active_turn_id probe: accept as NoTurn, no frame, no fetch_add. + if self.active_turn_id.lock().await.is_some() { + return Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }); + } + // F-4: ensure the app-server is awake before the wire write. When + // idle_ttl=None (default) the slot is always Active → one + // uncontended lock, no re-spawn (pre-F-4 parity). When suspended, + // this re-spawns + replays the resume handshake first. + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + // F-4: mark the turn in flight so the idle timer won't suspend the + // app-server mid-turn (the reader clears it at the terminal). + self.turn_in_flight.store(true, Ordering::SeqCst); + // REAL codex 0.137.0 turn-driver: `turn/start{threadId, input}` + // (verified against the aion-probe transcripts). Needs the bound + // threadId (waits briefly for the async thread/started). + let tid = self.bound_thread().await?; + let id = self.next_rpc_id(); + // GAP-A: register rpc_id → client_msg_id so the reader can emit + // PromptAccepted when codex's synchronous turn/start RESPONSE lands + // (that response is the "accepted" receipt; the conversation drains + // its pending queue on it). Only when the caller supplied a + // client_msg_id (otherwise there's nothing to correlate/drain). + if let Some(cmid) = metadata.client_msg_id { + self.pending_sends.lock().await.insert(id, cmid); + } + let frame = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "turn/start", + "params": { "threadId": tid, "input": build_input(&content) } + }); + self.write_frame(frame).await?; + let cur_gen = self.turn_gen.fetch_add(1, Ordering::SeqCst) + 1; + Ok(CommandReceipt { + accepted: true, + admission: Admission::Started, + turn_gen: cur_gen, + }) + } + Command::Cancel { target } => { + if let CancelTarget::Tool(_) = target { + return Err(BackendError::CommandNotSupported { command: "cancel_tool" }); + } + // REAL codex: `turn/interrupt{threadId, turnId}` (hard cancel). + // `turnId` is REQUIRED (non-Option on the wire). bound_thread first + // (establishes the handshake completed + lets the reader bind the + // active turn from turn/started). + let tid = self.bound_thread().await?; + // cancel-before-fold race (token-burn half): a cancel can arrive in the + // window between dispatch(Send) (which set turn_in_flight + wrote + // turn/start) and the reader binding `active_turn_id` from the async + // turn/started notification. If we no-op'd here, codex would keep + // running the turn (burning tokens) even though the user cancelled. So + // when a turn IS in flight but the id is not bound yet, briefly poll for + // the reader to bind it, then interrupt. A genuinely idle session + // (no turn_in_flight) still no-ops without writing a frame codex would + // reject. The orchestrator's lowered Cancel already folded the FSM to + // Idle (§004 S14); this only stops the backend's wasted work. + let mut active = self.active_turn_id.lock().await.clone(); + if active.is_none() && self.turn_in_flight.load(Ordering::SeqCst) { + for _ in 0..50 { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + active = self.active_turn_id.lock().await.clone(); + if active.is_some() || !self.turn_in_flight.load(Ordering::SeqCst) { + break; + } + } + } + let Some(turn_id) = active else { + return Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }); + }; + let id = self.next_rpc_id(); + let frame = json!({ + "jsonrpc": "2.0", "id": id, "method": "turn/interrupt", + "params": { "threadId": tid, "turnId": turn_id } + }); + self.write_frame(frame).await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::Steer { content } => { + // REAL codex: `turn/steer{threadId, expectedTurnId, input}` — a SOFT + // injection (queued to the active turn's input, NOT a hard cancel; + // contrast turn/interrupt). The optimistic `expectedTurnId` is the + // gated-steering wire: codex rejects (activeTurnNotSteerable) if the + // turn already ended. NoTurn admission (no new turn_gen — folds into + // the live turn, b-side FSM never sees Steer). + let tid = self.bound_thread().await?; + let Some(turn_id) = self.active_turn_id.lock().await.clone() else { + // No active turn to steer into. + return Err(BackendError::Transport("no active turn to steer".into())); + }; + let id = self.next_rpc_id(); + let frame = json!({ + "jsonrpc": "2.0", "id": id, "method": "turn/steer", + "params": { "threadId": tid, "expectedTurnId": turn_id, "input": build_input(&content) } + }); + self.write_frame(frame).await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::SetMode { mode } => { + // F-4: SetMode is a between-turn config write that can arrive while + // the session is idle-suspended → wake first so it writes to a live + // process (no-op when Active). + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + // for codex the mode axis IS the permission axis. The mode value is a + // DISCOVERED colon-prefixed profile id (`permissionProfile/list`), applied + // via `thread/settings/update{threadId, permissions:":workspace"}` — NOT the + // old collaborationMode object. U1 (live 0.139.0) froze the wire: the id + // MUST retain its leading colon (a bare id is rejected with + // "default_permissions requires a [permissions] table"), and `permissions` + // is mutually exclusive with `sandboxPolicy`. Unlike the old + // collaborationMode path this needs NO current_model. Applies to the NEXT + // turn; confirmed via the thread/settings/updated notif. + // + // `normalize_to_profile_id` is the codex analogue of legacy ACP + // `normalize_requested_mode`: a discovered colon id flows through verbatim, + // while an upgrading user's legacy bare value (`full-access`/`yolo`/… from + // an older persisted selection) rewrites onto its colon-id equivalent — + // never producing a value codex would reject. Validation against the LIVE + // catalog (a stale custom id) is the reader's job on the response (a reject + // surfaces as a Notice), exactly as legacy `set_mode` relied on the backend. + let profile_id = codex_perm::normalize_to_profile_id(&mode); + let tid = self.bound_thread().await?; + let id = self.next_rpc_id(); + // Register the rpc id so the reader claims the response: a JSON-RPC + // error (codex rejected the profile) surfaces as a Notice instead of + // being dropped (success converges via thread/settings/updated). + self.pending_set + .lock() + .await + .insert(id, format!("mode\u{2192}{profile_id}")); + let frame = json!({ + "jsonrpc": "2.0", "id": id, "method": "thread/settings/update", + "params": { + "threadId": tid, + "permissions": profile_id + } + }); + self.write_frame(frame).await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::SetModel { model } => { + // F-4: between-turn config write → wake a suspended session first. + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + // codex `thread/settings/update{threadId, model}` (verified frame: + // {"threadId":..,"model":"gpt-5.5"}). Applies to subsequent turns. + // Track it so a subsequent SetMode can build collaborationMode (M1). + let tid = self.bound_thread().await?; + *self.current_model.lock().await = Some(model.clone()); + let id = self.next_rpc_id(); + // Register the rpc id so the reader claims the response: a JSON-RPC + // error (codex rejected the model) surfaces as a Notice instead of + // being dropped (success converges via thread/settings/updated). + self.pending_set + .lock() + .await + .insert(id, format!("model\u{2192}{model}")); + let frame = json!({ + "jsonrpc": "2.0", "id": id, "method": "thread/settings/update", + "params": { "threadId": tid, "model": model } + }); + self.write_frame(frame).await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::AnswerAuth { method_id, credentials } => { + // Mid-session re-auth (R6/R15): the server raised + // `account/chatgptAuthTokens/refresh` (a blocking ServerRequest the + // reader surfaced as Permission{Auth} + stashed the wire id). We + // answer by writing the keyed JSON-RPC RESPONSE carrying the supplied + // tokens — `{access_token, chatgpt_account_id, chatgpt_plan_type?}` + // per ChatgptAuthTokensRefreshResponse — which UNBLOCKS the turn. + // The b-side waiting_on_auth -1 happens on serverRequest/resolved. + let Some(req_id) = self.pending_auth_id.lock().await.take() else { + return Err(BackendError::Transport("no pending auth refresh to answer".into())); + }; + let _ = method_id; // codex's refresh has ONE response shape; method_id is advisory + // RESPONSE KEY SHAPE = camelCase (accessToken/chatgptAccountId/chatgptPlanType). + // SCHEMA-CONFIRMED (protocol audit, 0.139.0): ChatgptAuthTokensRefreshResponse + // EXISTS in the generated schema, required [accessToken, chatgptAccountId], + // optional chatgptPlanType — exactly the camelCase shape written here. (The + // earlier "no schema, unverified best-guess" note was stale; the wire is + // correct. A snake_case "fix" would be WRONG — do not revert.) + // We accept either case from the CALLER (credentials) and normalize. + let access_token = credentials + .get("access_token") + .or_else(|| credentials.get("accessToken")) + .cloned() + .unwrap_or(Value::Null); + let account_id = credentials + .get("chatgpt_account_id") + .or_else(|| credentials.get("chatgptAccountId")) + .cloned() + .unwrap_or(Value::Null); + let mut result = json!({ "accessToken": access_token, "chatgptAccountId": account_id }); + if let Some(plan) = credentials + .get("chatgpt_plan_type") + .or_else(|| credentials.get("chatgptPlanType")) + { + result["chatgptPlanType"] = plan.clone(); + } + let frame = json!({ "jsonrpc": "2.0", "id": req_id, "result": result }); + self.write_frame(frame).await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::ListCheckpoints => { + // F-4: a between-turn query → wake a suspended session first. + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + // codex `thread/turns/list{threadId}` — register the rpc id so the + // reader maps the response's `data: Vec` to a + // `SessionEvent::CheckpointList{entries}` (O2 up-leg). First page + // only (we do not chase `next_cursor` — bounds the N2 risk). + let tid = self.bound_thread().await?; + let id = self.next_rpc_id(); + self.pending_discovery + .lock() + .await + .insert(id, DiscoveryKind::Checkpoints); + let frame = json!({ + "jsonrpc": "2.0", "id": id, "method": "thread/turns/list", + "params": { "threadId": tid } + }); + self.write_frame(frame).await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::Rewind { num_turns } => { + // G3 (T17 model): write codex `thread/rollback{threadId, numTurns}` + // (down) and register the rpc id so the reader maps the response to a + // `Rewound{to_turn}` receipt (up). Per the FROZEN seam contract T17, + // rewind is conversation-managed as a FORK (parent block stream is + // append-only, NOT truncated); session's job is exactly: mutate the + // backend history + emit the Rewound receipt the orchestrator + // rehydrates to and the conversation forks from. The earlier + // half-wiring wrote the rollback but emitted NO receipt (GAP-B) — that + // gap is what this closes. + // + // Idle-gated: a rollback mid-turn would race the in-flight turn's + // history. `active_turn_id` is the wire-truth proxy for Running (the + // reader sets it on turn/started, clears on turn/completed), so it + // catches a live turn regardless of who started it; reject a mid-turn + // rewind so it never silently corrupts a running turn. + if self.active_turn_id.lock().await.is_some() { + return Err(BackendError::Transport( + "cannot rewind while a turn is in flight".into(), + )); + } + if num_turns == 0 { + return Err(BackendError::Transport("rewind num_turns must be >= 1".into())); + } + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + let tid = self.bound_thread().await?; + let id = self.next_rpc_id(); + self.pending_discovery.lock().await.insert(id, DiscoveryKind::Rewind); + let frame = json!({ + "jsonrpc": "2.0", "id": id, "method": "thread/rollback", + "params": { "threadId": tid, "numTurns": num_turns } + }); + self.write_frame(frame).await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::AnswerPermission { + request_id, + decision, + selected: _, // codex approval is accept/decline; no pick-one label + answers: _, // codex has no AskUserQuestion; per-question answers N/A + } => { + let approved = matches!( + decision, + super::types::PermissionDecision::Approved | super::types::PermissionDecision::AllowAlways + ); + // codex CommandExecution/FileChangeApprovalDecision: accept (one-time) + // / acceptForSession (allow-always, no re-prompt) / decline. Map + // AllowAlways → acceptForSession so "don't ask again" persists for the + // session (was collapsed to a one-time accept → codex re-prompted every + // matching command). Schema: CommandExecutionRequestApprovalResponse. + let decision_str = match decision { + super::types::PermissionDecision::AllowAlways => "acceptForSession", + super::types::PermissionDecision::Approved => "accept", + super::types::PermissionDecision::Denied => "decline", + }; + // An elicitation request (ELICIT_PREFIX) needs `{action, content}`; + // a command/file approval needs `{decision}`. Both keyed by the same + // wire id we surfaced as Permission.request_id (prefix stripped for + // elicitation). The reducer never read the request_id, so the prefix + // is purely a dispatch-side wire-shape selector. + let frame = if let Some(raw_id) = request_id.strip_prefix(ELICIT_PREFIX) { + let id: Value = serde_json::from_str(raw_id).unwrap_or(Value::String(raw_id.to_string())); + // Elicitation has only accept/decline (no per-session variant); + // accept executes the tool / submits the form; decline cancels. + // We have no form values to fill (the conversation layer would + // supply them when a real form UI exists), so content is empty. + json!({ + "jsonrpc": "2.0", "id": id, + "result": { "action": if approved { "accept" } else { "decline" }, "content": {} } + }) + } else { + let id: Value = serde_json::from_str(&request_id).unwrap_or(Value::String(request_id.clone())); + json!({ + "jsonrpc": "2.0", "id": id, + "result": { "decision": decision_str } + }) + }; + self.write_frame(frame).await?; + // We answered it → drop from the REST-recovery registry so a + // subsequent GET /confirmations no longer resurfaces a card the user + // already resolved. `request_id` is the exact stored key (raw for + // tool/file, ELICIT_PREFIX-tagged for elicitation). + remove_pending_tool_approval(&self.pending_tool_approvals, &request_id); + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::Acknowledge { .. } => { + // User-ack of a completed turn (done-unseen → seen). NO wire frame — + // codex has no "acknowledge" concept; this folds at the + // conversation/fold-on-read layer, never the backend. Accept as a + // no-op so the conversation can record the ack locally (§C1). + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + // codex's reasoning effort IS a first-class `thread/settings/update` field: + // `ThreadSettingsUpdateParams.effort` ("Override the reasoning effort for + // subsequent turns" → ReasoningEffort enum), verified in the generated + // schema (samples/codex-cli/0.137.0/schema-full/ClientRequest.json, + // ThreadSettingsUpdateParams). So the effort option routes through the exact + // same wire SetModel/SetMode use — just with `{effort}` in params. The value + // reaching here is one of the model's advertised `supportedReasoningEfforts` + // (parsed verbatim from the catalog), so it is passed through unvalidated + // exactly like `model`; codex rejects an out-of-catalog value with a JSON-RPC + // error, which the reader surfaces as a Notice (validation is the reader's job + // on the response, matching SetModel/SetMode). Any OTHER config option has no + // codex wire and still rejects. + Command::SetConfigOption { option_id, value } + if matches!(option_id.as_str(), "effort" | "reasoning_effort" | "thought_level") => + { + // F-4: between-turn config write → wake a suspended session first. + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + let tid = self.bound_thread().await?; + let id = self.next_rpc_id(); + // Register the rpc id so the reader claims the response: a JSON-RPC error + // (codex rejected the effort) surfaces as a Notice instead of being + // dropped (success converges via thread/settings/updated). + self.pending_set + .lock() + .await + .insert(id, format!("effort\u{2192}{value}")); + let frame = json!({ + "jsonrpc": "2.0", "id": id, "method": "thread/settings/update", + "params": { "threadId": tid, "effort": value } + }); + self.write_frame(frame).await?; + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } + Command::SetConfigOption { .. } => Err(BackendError::CommandNotSupported { + command: "set_config_option", + }), + // codex streams per-turn usage (thread/tokenUsage/updated) but has no + // on-demand cumulative context/cost QUERY wire → reject (cap=false). + Command::QuerySessionInfo { .. } => Err(BackendError::CommandNotSupported { + command: "query_session_info", + }), + } + } + + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + let rx = self.event_tx.subscribe(); + futures_util::stream::unfold(rx, |mut rx| async move { + loop { + match rx.recv().await { + Ok(env) => return Some((env, rx)), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + }) + .boxed() + } + + fn capabilities(&self) -> Capabilities { + // B-CODEX-MODEL-LIST: merge the handshake-discovered models/modes into the + // snapshot (the static base has them empty; the reader fills `discovered` + // from model/list + permissionProfile/list responses — the latter mapped to the + // fixed permission-tier mode enum, feature 012). Read-only sync lock. + let mut caps = self.capabilities.clone(); + let disc = self.discovered.lock().unwrap_or_else(|e| e.into_inner()); + if !disc.models.is_empty() { + caps.available_models = disc.models.clone(); + } + if !disc.modes.is_empty() { + caps.available_modes = disc.modes.clone(); + } + caps + } + + /// REST-recovery (`GET /confirmations`) source: the transient registry of + /// currently-open (unanswered) codex approval requests — command/file + /// approvals (`*/requestApproval`) and MCP elicitations. The reader inserts on + /// each such reverse-RPC, and removes on `serverRequest/resolved` (codex + /// retracted/answered) and `dispatch(AnswerPermission)` (we answered). Without + /// this override (the default empty `Vec`), a codex tool/file approval raised + /// before the client subscribed — or lost on a page reload — could never be + /// rebuilt, and the turn hung forever waiting for an answer. The recovered + /// card's id==call_id==request_id, matching the live `Permission` frame so a + /// duplicate live+recovered pair de-dups. codex approvals carry no question + /// payload (AskUserQuestion is claude-only), so `questions` is always `None`; + /// the raw command body is NOT exposed (TIO-13) — only the approval-class title. + fn pending_permission_requests(&self) -> Vec { + self.pending_tool_approvals + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .map(|(request_id, title)| PendingPermissionView { + request_id: request_id.clone(), + tool_name: title.clone(), + questions: None, + }) + .collect() + } +} + +/// Map our multimodal `ContentBlock`s → codex `turn/start.input` items. codex +/// `UserInput` (source-verified turn.rs:266–297) carries Text, Image, LocalImage, +/// Skill and Mention. We map text, image (`image:true`) and file attachments +/// (`resource:true` → `Mention`). Audio/at_mention are not advertised in +/// codex_capabilities, so dispatch (§C6, `BlockSet::allows`) rejects them before +/// reaching here — the `_ => None` arm is a defensive belt-and-suspenders. +fn build_input(content: &[ContentBlock]) -> Vec { + content + .iter() + .filter_map(|b| match b { + ContentBlock::Text(t) => Some(json!({ "type": "text", "text": t })), + ContentBlock::Image { data, media_type } => { + // codex `UserInput::Image { url }` (source-verified turn.rs:143-146, + // wire `{type:"image", url}`). We pass a data: URL of the bytes. + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(data); + Some(json!({ "type": "image", "url": format!("data:{media_type};base64,{b64}") })) + } + ContentBlock::ResourceLink { uri, .. } => { + // Deliver a file BY REFERENCE as a TEXT element (the model spawns its + // own read tool), NOT as codex `UserInput::Mention`. + // + // ROOT CAUSE (source-verified, openai/codex models.rs:1718 + // `from_user_input` → :1762): `Mention` contributes ZERO content to the + // model prompt — it is consumed ONLY by plugins/mentions.rs for App/ + // Plugin (`plugin://`/connector/registered-app) references; a plain + // filesystem path matches neither, resolves to nothing, AND leaves the + // turn loop's follow-up state unresolved so codex never emits + // turn/completed → the FSM hangs Running forever (the "no-terminal" + // prod bug). Plain Text turns terminate cleanly. So a file attachment + // must ride Text (mirrors the claude adapter's `[Attached file: ]`). + // Strip a `file://` scheme so the path reads cleanly. Pinned by + // protocols/samples/codex-cli/0.139.0/_probe_hang_isolate.py (mention = + // no terminal; text = terminal). + let path = uri.strip_prefix("file://").unwrap_or(uri); + Some(json!({ "type": "text", "text": format!("[Attached file: {path}]") })) + } + // Audio / at_mention: not in codex prompt_blocks → drop. + _ => None, + }) + .collect() +} + +impl CodexSessionBackend { + pub fn session_id(&self) -> &str { + &self.session_id + } +} + +impl Drop for CodexSessionBackend { + /// M5: abort the live reader (via the controller's mirrored AbortHandle, no + /// await) so its `Arc` clone is released and the persistent codex + /// subprocess is reaped (kill_on_drop). Without this the reader blocks forever + /// on `next_line()` (codex stdout never EOFs), pinning the `ManagedProcess` + /// alive → orphaned child. Also stop the idle timer if one was running. + fn drop(&mut self) { + self.suspend.abort_on_drop(); + if let Some(timer) = &self.idle_timer { + timer.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::PermissionKind; + use crate::testing::FakeAgentIo; + use futures_util::StreamExt; + + /// Build a FakeAgentIo replaying codex JSON-RPC lines, then collect the + /// SessionEvents the backend surfaces (excluding the EOF Detached). + async fn drive_codex(lines: &[&str]) -> Vec { + let bytes = format!("{}\n", lines.join("\n")).into_bytes(); + let fake = FakeAgentIo::new( + bytes, + Some(crate::event::ExitStatusLite { + code: Some(0), + signal: None, + }), + ); + fake.release_exit(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let mut events = backend.events(); + let mut out = Vec::new(); + for _ in 0..100 { + match tokio::time::timeout(std::time::Duration::from_secs(2), events.next()).await { + Ok(Some(env)) => { + assert_eq!(env.session_id, "codex-1", "demux by logical id"); + match env.event { + SessionEvent::Detached { .. } => break, + ev => out.push(ev), + } + } + _ => break, + } + } + out + } + + // ===== build_input multimodal lowering ===== + + #[test] + fn build_input_maps_resource_link_to_text_reference_not_mention() { + // A file attachment (ResourceLink) lowers to a TEXT element `[Attached file: + // ]`, NOT codex `UserInput::Mention`. Source-verified root cause: a + // Mention contributes nothing to the model prompt (models.rs:1762, only + // App/Plugin refs are consumed) and leaves the turn loop unterminated → the + // no-terminal hang. A text reference lets the model spawn its own read tool + // (mirrors the claude adapter) and terminates cleanly. NO `mention` item must + // ever be emitted for a file. + let items = build_input(&[ + ContentBlock::Text("read this".into()), + ContentBlock::ResourceLink { + uri: "file:///tmp/sub/report.pdf".into(), + mime_type: Some("application/pdf".into()), + }, + ]); + assert_eq!(items.len(), 2, "text + file-ref both lowered"); + assert_eq!(items[0]["type"], "text"); + assert_eq!(items[1]["type"], "text", "file rides Text, NOT mention"); + assert_eq!( + items[1]["text"], "[Attached file: /tmp/sub/report.pdf]", + "file:// scheme stripped, delivered as a text reference" + ); + assert!( + !items.iter().any(|i| i["type"] == "mention"), + "no `mention` item is ever emitted for a file (it hangs the turn)" + ); + } + + #[test] + fn build_input_file_ref_keeps_bare_path_without_scheme() { + // A path without a URL scheme passes through verbatim in the text reference. + let items = build_input(&[ContentBlock::ResourceLink { + uri: "/abs/notes.txt".into(), + mime_type: None, + }]); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["type"], "text"); + assert_eq!(items[0]["text"], "[Attached file: /abs/notes.txt]"); + } + + // ===== ⭐ A1: ThreadItem closed-enum panic guard (freeze-blocker) ===== + + /// PROPERTY (§F.3 input field-value boundary for the codex map_item entry, + /// generalizes the A1 anti-panic guard below): for ANY `item/*` params shape — + /// arbitrary `type` (known / unknown / absent), arbitrary / missing `id`, + /// arbitrary payload — `map_item`: + /// 1. NEVER panics (the real codex ThreadItem is a closed 16-variant enum that + /// would panic on an unknown type; we parse the type STRING with a + /// fallthrough, so malformed/future input is data, not a crash); + /// 2. an UNKNOWN type → exactly an `AdapterSpecific{tag:"codex_item:"}` + /// (never silently dropped, never a closed-variant construction). + /// + /// `map_item` is a pure free fn, so this needs no backend/fixture — it sweeps the + /// type/field value space directly. claude tool_use has the sibling proptest + /// (`prop_parse_assistant_never_emits_blank_name_toolcall`). + #[test] + fn prop_map_item_never_panics_unknown_type_is_adapter_specific() { + use proptest::prelude::*; + let known = prop_oneof![ + Just("agentMessage"), + Just("reasoning"), + Just("commandExecution"), + Just("userMessage"), + Just("collabAgent"), + ] + .prop_map(|s| s.to_string()); + // unknown type: any ident-ish string NOT in the known set. + let unknown = "[a-zA-Z][a-zA-Z0-9]{0,12}".prop_filter("must be unknown", |s| { + !matches!( + s.as_str(), + "agentMessage" | "reasoning" | "commandExecution" | "mcpToolCall" | "dynamicToolCall" | "fileChange" + ) + }); + let type_strat = prop_oneof![Just(None), known.prop_map(Some), unknown.prop_map(Some)]; + let id_strat = prop_oneof![Just(None), "[a-z0-9-]{0,6}".prop_map(Some)]; + + proptest!(|(ty in type_strat, id in id_strat, completed in any::())| { + let mut item = serde_json::json!({"payload": {"k": 1}}); + if let Some(t) = &ty { item["type"] = serde_json::Value::String(t.clone()); } + if let Some(i) = &id { item["id"] = serde_json::Value::String(i.clone()); } + let params = serde_json::json!({"item": item, "threadId":"th", "turnId":"t"}); + + let events = map_item(¶ms, completed); // (1) must not panic + + // (2) an unknown type (present, non-empty, not a known kind) ⟹ AdapterSpecific + // tagged codex_item:. (Known kinds / absent type take other arms.) + if let Some(t) = &ty { + let is_known = matches!( + t.as_str(), + "agentMessage" | "reasoning" | "commandExecution" | "mcpToolCall" + | "dynamicToolCall" | "fileChange" | "userMessage" | "collabAgent" + | "webSearch" | "imageGeneration" + ); + if !t.is_empty() && !is_known { + prop_assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::AdapterSpecific { tag, .. } if tag == &format!("codex_item:{t}") + )), + "unknown item type {t:?} must surface as AdapterSpecific, got {events:?}" + ); + } + } + }); + } + + #[tokio::test] + async fn a1_unknown_item_type_does_not_panic_falls_to_adapter_specific() { + // A FUTURE codex ThreadItem.type our code has never seen. The real codex + // ThreadItem is a CLOSED 16-variant enum that would PANIC on this during + // deserialization. We parse `item.type` as a string with a fallthrough, + // so it becomes AdapterSpecific — NEVER a panic. This is the §C5 A1 fix. + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th1"}}}"#, + r#"{"jsonrpc":"2.0","method":"item/started","params":{"item":{"type":"aiGeneratedTimeMachine","id":"x9","payload":{"future":true}},"threadId":"th1","turnId":"t1"}}"#, + ]) + .await; + // The unknown item surfaced as AdapterSpecific, tagged, payload preserved. + let found = events.iter().any( + |e| matches!(e, SessionEvent::AdapterSpecific { tag, .. } if tag == "codex_item:aiGeneratedTimeMachine"), + ); + assert!( + found, + "unknown ThreadItem.type must fall to AdapterSpecific (no panic), got {events:?}" + ); + } + + // ===== Addendum 9: BackendBound (backend_session_id → conversation) ===== + + #[tokio::test] + async fn thread_started_lowers_backend_bound_with_thread_id() { + // Addendum 9: on thread/started the adapter binds the threadId AND lowers + // BackendBound{Some(threadId)} so the conversation can persist it as the + // resume anchor. (The threadId still NEVER appears in any other envelope — + // SessionEnvelope.session_id stays the logical id; BackendBound is the one + // explicit channel.) + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-resume-anchor"}}}"#, + ]) + .await; + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::BackendBound { backend_session_id: Some(tid) } if tid == "th-resume-anchor" + )), + "thread/started lowers BackendBound{{Some(threadId)}}, got {events:?}" + ); + } + + #[tokio::test] + async fn eof_lowers_backend_bound_none_when_session_was_bound() { + // Addendum 9: backend gone (EOF) → BackendBound{None} so the conversation + // clears its stale anchor (resuming a dead thread would fail). + let events = + drive_codex(&[r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-1"}}}"#]).await; // drive_codex EOFs after the scripted lines → reader runs the lost-binding path + // Both a Some (on started) then a None (on EOF) must appear, in order. + let bounds: Vec<_> = events + .iter() + .filter_map(|e| match e { + SessionEvent::BackendBound { backend_session_id } => Some(backend_session_id.clone()), + _ => None, + }) + .collect(); + assert_eq!( + bounds, + vec![Some("th-1".to_string()), None], + "BackendBound Some(on started) then None(on EOF/lost), got {bounds:?}" + ); + } + + // ===== ⭐ A2/A3: reverse-RPC hang guard (freeze-blocker) ===== + + #[tokio::test] + async fn a2_a3_reverse_rpc_is_handled_not_hung() { + // Blocking ServerRequests (method + id). If unhandled, the JSON-RPC + // channel deadlocks and the turn hangs forever. We handle them by class: + // pure infra (attestation) → auto-answered diagnostic; mid-session auth + // refresh → Permission{Auth} (NOT auto-answered — a human answers). The + // reader keeps draining either way (it never blocks on a response). We + // assert all three are observed promptly — proving no hang. + let events = tokio::time::timeout( + std::time::Duration::from_secs(3), + drive_codex(&[ + r#"{"jsonrpc":"2.0","id":1,"method":"account/chatgptAuthTokens/refresh","params":{}}"#, + r#"{"jsonrpc":"2.0","id":2,"method":"attestation/generate","params":{"nonce":"abc"}}"#, + r#"{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"itemId":"m1","delta":"after the reverse-rpc"}}"#, + ]), + ) + .await + .expect("must NOT hang on blocking reverse-RPC"); + + // attestation → auto-answered diagnostic (pure infra, unblocked on the wire). + let auto_answered = events + .iter() + .filter( + |e| matches!(e, SessionEvent::AdapterSpecific { tag, .. } if tag == "codex_reverse_rpc_auto_answered"), + ) + .count(); + assert_eq!(auto_answered, 1, "attestation auto-answered (pure infra)"); + // auth refresh → Permission{Auth} (mid-session re-auth; human-answered). + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::Permission { + kind: PermissionKind::Auth, + .. + } + )), + "auth-token refresh surfaces as Permission(Auth), got {events:?}" + ); + // Crucially, the reader CONTINUED past the blocking requests to deliver the + // following notification — proving it never blocked. + assert!( + events + .iter() + .any(|e| matches!(e, SessionEvent::MessageDelta { text, .. } if text == "after the reverse-rpc")), + "reader continued past reverse-RPC to deliver the next notification" + ); + } + + #[tokio::test] + async fn approval_reverse_rpc_surfaces_as_permission() { + // commandExecution/fileChange approval ServerRequests (response = {decision}, + // which is what AnswerPermission writes) → user-facing Permission (Tool). + for m in [ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + ] { + let events = drive_codex(&[&format!( + r#"{{"jsonrpc":"2.0","id":7,"method":"{m}","params":{{"command":"rm -rf /"}}}}"# + )]) + .await; + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::Permission { + kind: PermissionKind::Tool, + .. + } + )), + "{m} surfaces as Permission(Tool), got {events:?}" + ); + } + } + + /// REST-recovery parity with claude: a codex tool approval is LISTED by + /// `pending_permission_requests()` while open, and the list is EMPTY after + /// `dispatch(AnswerPermission)` consumes it. Without the registry the recovery + /// read returned empty and a reloaded `waiting_confirmation` codex turn hung + /// forever (the id needed to answer lived only in the missed live frame). + #[tokio::test] + async fn codex_pending_tool_approval_lists_open_then_clears_on_answer() { + // Gate the reverse-RPC so it arrives AFTER we subscribe; keep the process + // alive (never_exits) so the registry is not torn down by an EOF. + let fake = FakeAgentIo::never_exits(Vec::new()).with_gated_tail( + concat!( + r#"{"jsonrpc":"2.0","id":7,"method":"item/commandExecution/requestApproval","params":{"command":"rm -rf /"}}"#, + "\n", + ) + .as_bytes() + .to_vec(), + ); + let releaser = fake.stdout_releaser(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let mut events = backend.events(); + releaser(); + + // Wait for the live Permission frame so the reader has processed the insert. + let request_id = tokio::time::timeout(std::time::Duration::from_secs(3), async { + while let Some(env) = events.next().await { + if let SessionEvent::Permission { + request_id, + kind: PermissionKind::Tool, + .. + } = env.event + { + return Some(request_id); + } + } + None + }) + .await + .expect("timed out waiting for Permission") + .expect("a Tool Permission frame"); + + // OPEN: recovery lists exactly this pending approval, keyed by request_id. + let open = backend.pending_permission_requests(); + assert_eq!(open.len(), 1, "one open approval recovered, got {open:?}"); + assert_eq!(open[0].request_id, request_id, "recovered id == live request_id"); + assert_eq!(open[0].tool_name, "CommandExecution"); + assert!(open[0].questions.is_none(), "codex approvals carry no question payload"); + + // ANSWER: dispatch(AnswerPermission) → the registry entry is dropped. + backend + .dispatch(Command::AnswerPermission { + request_id: request_id.clone(), + decision: crate::PermissionDecision::Approved, + selected: None, + answers: Vec::new(), + }) + .await + .expect("AnswerPermission accepted"); + assert!( + backend.pending_permission_requests().is_empty(), + "recovery list EMPTY after the approval is answered" + ); + } + + #[tokio::test] + async fn command_execution_output_delta_maps_to_tool_output_delta() { + // codex item/commandExecution/outputDelta → ToolOutputDelta (plaintext delta + // keyed by itemId; verified live 0.139.0). The full output still rides the + // completed item's aggregatedOutput → ToolResult. + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"item/commandExecution/outputDelta","params":{"threadId":"th1","turnId":"t1","itemId":"call_0","delta":"line-2\n"}}"#, + ]) + .await; + let got = events.iter().find_map(|e| match e { + SessionEvent::ToolOutputDelta { item_id, text } => Some((item_id.clone(), text.clone())), + _ => None, + }); + assert_eq!(got, Some(("call_0".into(), "line-2\n".into()))); + } + + #[tokio::test] + async fn diagnostic_notifications_map_to_notice_or_heartbeat() { + use crate::event::NoticeLevel; + // warning / guardianWarning → Notice{Warning, message} + for m in ["warning", "guardianWarning"] { + let events = drive_codex(&[&format!( + r#"{{"jsonrpc":"2.0","method":"{m}","params":{{"message":"disk almost full"}}}}"# + )]) + .await; + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::Notice { level: NoticeLevel::Warning, message } if message == "disk almost full" + )), + "{m} → Notice(Warning), got {events:?}" + ); + } + // deprecationNotice → Notice{Info, summary — details} + let dep = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"deprecationNotice","params":{"summary":"--foo is deprecated","details":"use --bar"}}"#, + ]) + .await; + assert!( + dep.iter().any(|e| matches!( + e, + SessionEvent::Notice { level: NoticeLevel::Info, message } + if message == "--foo is deprecated — use --bar" + )), + "deprecationNotice → Notice(Info) with joined details, got {dep:?}" + ); + // configWarning → Notice{Warning, summary} + let cfg = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"configWarning","params":{"summary":"unknown key X","path":"~/.codex/config.toml"}}"#, + ]) + .await; + assert!( + cfg.iter() + .any(|e| matches!(e, SessionEvent::Notice { level: NoticeLevel::Warning, message } if message == "unknown key X")), + "configWarning → Notice(Warning), got {cfg:?}" + ); + // error{willRetry:true} → Heartbeat (transient retry, not a duplicate terminal) + let retry = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"error","params":{"threadId":"th1","turnId":"t1","willRetry":true,"error":{"message":"503"}}}"#, + ]) + .await; + assert!( + retry.iter().any(|e| matches!(e, SessionEvent::Heartbeat)), + "error{{willRetry:true}} → Heartbeat, got {retry:?}" + ); + // error{willRetry:false} with NO turn in-flight (drive_codex dispatches no + // Send) → still no Notice/Heartbeat, and no synthetic terminal either (the + // fatal-terminal arm is gated on turn_in_flight; a fatal error outside a turn + // must not fold the FSM). The IN-FLIGHT fatal-terminal behavior is covered by + // `codex_fatal_error_in_flight_synthesizes_terminal` below. + let fatal = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"error","params":{"threadId":"th1","turnId":"t1","willRetry":false,"error":{"message":"boom"}}}"#, + ]) + .await; + assert!( + !fatal + .iter() + .any(|e| matches!(e, SessionEvent::Notice { .. } | SessionEvent::Heartbeat)), + "error{{willRetry:false}} (no turn in-flight) emits no Notice/Heartbeat, got {fatal:?}" + ); + } + + /// #codex-no-terminal (fatal error): when a turn IS in flight and codex sends + /// `error{willRetry:false}` (fatal, no guaranteed turn/completed), the reader + /// synthesizes an is_error TurnResult so the FSM leaves Running instead of hanging + /// forever. A later turn/completed for the same turn is absorbed (terminated guard). + #[tokio::test] + async fn codex_fatal_error_in_flight_synthesizes_terminal() { + // gated_tail so the fatal error flows AFTER we mark the turn in-flight + subscribe. + let fake = FakeAgentIo::never_exits(Vec::new()).with_gated_tail( + concat!( + r#"{"jsonrpc":"2.0","method":"turn/started","params":{"turn":{"id":"t1"}}}"#, + "\n", + r#"{"jsonrpc":"2.0","method":"error","params":{"threadId":"th1","turnId":"t1","willRetry":false,"error":{"message":"boom fatal"}}}"#, + "\n", + ) + .as_bytes() + .to_vec(), + ); + let releaser = fake.stdout_releaser(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + backend.mark_turn_in_flight_for_test(); + let mut events = backend.events(); + releaser(); + + let tr = tokio::time::timeout(std::time::Duration::from_secs(3), async { + while let Some(env) = events.next().await { + if let SessionEvent::TurnResult { + is_error, result_text, .. + } = env.event + { + return Some((is_error, result_text)); + } + } + None + }) + .await + .expect("timed out") + .expect("a TurnResult"); + assert!(tr.0, "fatal error in flight → is_error terminal"); + assert!( + tr.1.contains("boom fatal"), + "the fatal error message rides result_text, got {:?}", + tr.1 + ); + } + + /// Protocol-audit fix (MED): thread/status/changed → systemError is a FATAL fault + /// that codex may NOT follow with a turn/completed → must be a synthesized terminal + /// (was treated as advisory → FSM hangs Running). A later turn/completed is absorbed. + #[tokio::test] + async fn codex_system_error_status_synthesizes_terminal() { + let sys_err = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"turn/started","params":{"turn":{"id":"t1"}}}"#, + r#"{"jsonrpc":"2.0","method":"thread/status/changed","params":{"threadId":"th1","status":{"type":"systemError"}}}"#, + ]) + .await; + assert!( + sys_err + .iter() + .any(|e| matches!(e, SessionEvent::TurnResult { is_error: true, .. })), + "thread/status/changed→systemError must synthesize an is_error terminal (not hang), got {sys_err:?}" + ); + } + + #[tokio::test] + async fn turn_diff_updated_maps_to_turn_diff_updated() { + // codex turn/diff/updated → TurnDiffUpdated (full cumulative unified diff; + // verified live 0.139.0). + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"turn/diff/updated","params":{"threadId":"th1","turnId":"t1","diff":"diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n"}}"#, + ]) + .await; + let got = events.iter().find_map(|e| match e { + SessionEvent::TurnDiffUpdated { diff } => Some(diff.clone()), + _ => None, + }); + assert!(got.is_some_and(|d| d.contains("diff --git")), "got {events:?}"); + } + + #[tokio::test] + async fn elicitation_request_surfaces_as_permission_with_elicit_prefix() { + // mcpServer/elicitation/request (LIVE-confirmed 0.139.0) → Permission(Tool), + // request_id tagged with ELICIT_PREFIX so dispatch writes {action,content} + // (not {decision}). The elicitation context (message/schema/mode) rides input. + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","id":5,"method":"mcpServer/elicitation/request","params":{"serverName":"elicitprobe","mode":"form","message":"Pick a color","requestedSchema":{"type":"object","properties":{"color":{"type":"string"}}}}}"#, + ]) + .await; + let perm = events + .iter() + .find_map(|e| match e { + SessionEvent::Permission { + request_id, + kind: PermissionKind::Tool, + input, + .. + } => Some((request_id.clone(), input.clone())), + _ => None, + }) + .expect("elicitation surfaces as Permission(Tool)"); + assert!( + perm.0.starts_with(ELICIT_PREFIX), + "request_id is tagged with the elicit prefix (so dispatch writes {{action,content}}), got {}", + perm.0 + ); + let input = perm.1.expect("elicitation carries its context as input"); + assert_eq!(input["message"], "Pick a color"); + assert_eq!(input["mode"], "form"); + assert!(input["requestedSchema"]["properties"].get("color").is_some()); + } + + #[tokio::test] + async fn command_request_approval_surfaces_as_permission_tool() { + // item/commandExecution/requestApproval (the codex command/file approval the + // live tests can only exercise non-deterministically — codex raises it at + // model discretion) → Permission{Tool} carrying the reverse-RPC wire id as + // request_id, NOT elicit-prefixed (so dispatch answers with `{decision}`, not + // `{action,content}`). This pins the approval-surface mechanism deterministically + // so the tolerant live approve/deny tests are integration smoke, not the only + // coverage of the round-trip. + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","id":11,"method":"item/commandExecution/requestApproval","params":{"command":"echo hi","cwd":"/tmp"}}"#, + ]) + .await; + let (request_id, kind) = events + .iter() + .find_map(|e| match e { + SessionEvent::Permission { request_id, kind, .. } => Some((request_id.clone(), *kind)), + _ => None, + }) + .expect("commandExecution/requestApproval surfaces as Permission"); + assert_eq!(kind, PermissionKind::Tool, "command approval is a Tool permission"); + assert_eq!(request_id, "11", "request_id is the reverse-RPC wire id (un-prefixed)"); + assert!( + !request_id.starts_with(ELICIT_PREFIX), + "a command approval must NOT be elicit-prefixed (it answers with {{decision}}), got {request_id}" + ); + } + + #[tokio::test] + async fn dispatch_answer_permission_command_writes_decision_body() { + // The other half of the command-approval round-trip: answering a plain + // (un-prefixed) approval id writes `{result:{decision:"accept"|"decline"| + // "acceptForSession"}}` keyed by the wire id — the body codex's command/file + // requestApproval expects. (The elicit path's {action,content} body is covered + // by dispatch_answer_permission_elicit_writes_action_content_not_decision.) + for (decision, expected) in [ + (super::super::types::PermissionDecision::Approved, "accept"), + (super::super::types::PermissionDecision::Denied, "decline"), + (super::super::types::PermissionDecision::AllowAlways, "acceptForSession"), + ] { + let fake = fake_with_binding("th-1", None); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + backend + .dispatch(Command::AnswerPermission { + request_id: "11".into(), + decision, + selected: None, + answers: vec![], + }) + .await + .expect("accepted"); + let written = captured_str(&captured).await; + assert!( + written.contains(&format!(r#""decision":"{expected}""#)), + "command answer writes {{decision:{expected}}}, got: {written}" + ); + assert!( + !written.contains(r#""action""#), + "command answer must NOT use the elicit {{action}} body, got: {written}" + ); + assert!(written.contains(r#""id":11"#), "keyed by the wire id, got: {written}"); + } + } + + #[tokio::test] + async fn m2_permissions_escalation_approval_is_not_surfaced_as_decision_permission() { + // M2: item/permissions/requestApproval needs a {permissions, scope} response, + // NOT the {decision} body AnswerPermission writes. Surfacing it as + // Permission(Tool) would let the conversation answer it with a malformed body + // codex rejects. It MUST fall through to the clean -32601 reject (unblocks the + // channel) and surface a diagnostic — NOT a Permission. + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","id":9,"method":"item/permissions/requestApproval","params":{"reason":"escalate"}}"#, + ]) + .await; + assert!( + !events.iter().any(|e| matches!(e, SessionEvent::Permission { .. })), + "permissions/requestApproval must NOT surface as a {{decision}}-answerable Permission, got {events:?}" + ); + assert!( + events + .iter() + .any(|e| matches!(e, SessionEvent::AdapterSpecific { tag, .. } if tag == "codex_reverse_rpc")), + "it falls through to the clean-reject diagnostic, got {events:?}" + ); + } + + #[tokio::test] + async fn m5_dropping_backend_aborts_reader_task() { + // M5: dropping a CodexSessionBackend MUST abort the reader task (codex's + // persistent stdout never EOFs, so the reader would block forever, pinning + // the subprocess alive). We use never_exits so the reader truly blocks on + // next_line; grab the JoinHandle's abort-handle, drop the backend, and assert + // the task is finished (aborted). + let fake = FakeAgentIo::never_exits(Vec::new()); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let handle = backend + .suspend + .current_abort_handle() + .expect("live reader has an abort handle"); + assert!( + !handle.is_finished(), + "reader is live (blocked on next_line) before drop" + ); + drop(backend); + // Give the runtime a tick to process the abort. + for _ in 0..40 { + if handle.is_finished() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!(handle.is_finished(), "dropping the backend aborts the reader task (M5)"); + } + + #[tokio::test] + async fn infra_reverse_rpc_writes_real_error_response_to_stdin() { + // A2/A3 is now a REAL deadlock guard, not just a diagnostic: a blocking + // PURE-INFRA ServerRequest (attestation — no human can satisfy it) gets an + // actual JSON-RPC ERROR response written back to stdin (keyed by the same + // id), so codex's blocking call returns instead of hanging the turn. The + // fake captures the bytes we wrote. (Auth refresh is handled differently — + // it surfaces as Permission{Auth}; see dispatch_answer_auth.) + let bytes = format!( + "{}\n", + r#"{"jsonrpc":"2.0","id":42,"method":"attestation/generate","params":{"nonce":"x"}}"# + ) + .into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); // stay alive so we can read what we wrote + let captured = fake.captured_stdin(); + let _backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let written = captured_str(&captured).await; + assert!( + written.contains(r#""id":42"#), + "response keyed to the request id, got: {written}" + ); + assert!( + written.contains(r#""error""#), + "wrote a JSON-RPC error (not a hang), got: {written}" + ); + assert!( + written.contains("-32601"), + "the unsupported-method code, got: {written}" + ); + } + + // ===== notification → SessionEvent mapping (transport-agnostic fold) ===== + + #[tokio::test] + async fn full_codex_turn_maps_to_canonical_events() { + // A realistic codex turn: thread/started → turn/started → item deltas → + // tokenUsage → turn/completed. Asserts the wire maps onto the SAME + // canonical vocabulary claude uses (the §C transport-agnostic proof). + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th1"}}}"#, + r#"{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"th1","turn":{"id":"t1"}}}"#, + r#"{"jsonrpc":"2.0","method":"item/started","params":{"item":{"type":"agentMessage","id":"m1","text":""},"threadId":"th1","turnId":"t1"}}"#, + r#"{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":"th1","turnId":"t1","itemId":"m1","delta":"hello"}}"#, + r#"{"jsonrpc":"2.0","method":"thread/tokenUsage/updated","params":{"threadId":"th1","turnId":"t1","tokenUsage":{"last":{"inputTokens":10,"outputTokens":5,"totalTokens":15}}}}"#, + r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"th1","turn":{"id":"t1","status":"completed"}}}"#, + ]) + .await; + + assert!( + events + .iter() + .any(|e| matches!(e, SessionEvent::MessageDelta { text, .. } if text == "hello")), + "agentMessage/delta → MessageDelta" + ); + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::UsageDelta { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + .. + } + )), + "tokenUsage.last → UsageDelta (G6: native per-turn, no subtraction)" + ); + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::TurnResult { + is_error: false, + outcome: TurnOutcome::Completed { .. }, + .. + } + )), + "turn/completed status:completed maps to TurnResult is_error:false Completed" + ); + // GAP-E: item/started → ItemStarted bracket (the partial-lifecycle signal). + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::ItemStarted { item_id, kind: crate::event::ItemKind::Text } if item_id == "m1" + )), + "item/started(agentMessage) → ItemStarted{{m1, Text}}, got {events:?}" + ); + } + + #[tokio::test] + async fn gap_e_tool_item_emits_started_and_completed_brackets() { + // GAP-E: a tool item gets ItemStarted (on started) + ToolCall, then + // ToolResult + ItemCompleted (on completed) — the C5.3 frozen brackets + // around the content events. + // Bug-hunt #1: drive a real command body so the ToolCall assertion can pin the + // LOAD-BEARING fields (name + input args), not just the id. map_item carries + // them (codex_conn.rs ~1649, Gap #4/H2) and the conversation finalizer threads + // them to the FileDiff/tool card — a regression dropping name or input was + // unpinned by any codex test (the old assertion used `..`). + let started = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"item/started","params":{"item":{"type":"commandExecution","id":"c1","command":"echo hi","cwd":"/tmp"},"threadId":"th1","turnId":"t1"}}"#, + ]) + .await; + assert!( + started.iter().any( + |e| matches!(e, SessionEvent::ItemStarted { item_id, kind: crate::event::ItemKind::Tool } if item_id == "c1") + ), + "tool item/started → ItemStarted{{Tool}}, got {started:?}" + ); + let tool_call = started + .iter() + .find_map(|e| match e { + SessionEvent::ToolCall { + tool_use_id, + name, + input, + .. + } if tool_use_id == "c1" => Some((name.clone(), input.clone())), + _ => None, + }) + .unwrap_or_else(|| panic!("a ToolCall content event for c1, got {started:?}")); + assert_eq!( + tool_call.0, "commandExecution", + "#1: ToolCall.name pinned (was unpinned)" + ); + assert_eq!( + tool_call.1.get("command").and_then(serde_json::Value::as_str), + Some("echo hi"), + "#1: ToolCall.input carries the command body (was unpinned → a producer regression went green), got {:?}", + tool_call.1 + ); + let completed = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"commandExecution","id":"c1"},"threadId":"th1","turnId":"t1"}}"#, + ]) + .await; + assert!( + completed + .iter() + .any(|e| matches!(e, SessionEvent::ItemCompleted { item_id, .. } if item_id == "c1")), + "tool item/completed → ItemCompleted, got {completed:?}" + ); + assert!( + completed + .iter() + .any(|e| matches!(e, SessionEvent::ToolResult { tool_use_id, .. } if tool_use_id == "c1")), + "and the ToolResult content event, got {completed:?}" + ); + } + + #[tokio::test] + async fn completed_tool_item_carries_is_error_on_failure() { + // 009 R7/H3 (codex·ACP symmetry — codex leg): a completed tool item is + // FAILED when status=="failed" OR a command exited non-zero (exitCode!=0). + // The ToolResult MUST carry is_error so a failed tool is not rendered as a + // success. codex had the parse code (map_item) but no failing-tool test. + + // (a) status:"failed" → is_error:true, output carried. + let failed = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"mcpToolCall","id":"tf1","status":"failed","aggregatedOutput":"tool blew up"},"threadId":"th1","turnId":"t1"}}"#, + ]) + .await; + assert!( + failed.iter().any(|e| matches!(e, + SessionEvent::ToolResult { tool_use_id, is_error: true, content, .. } + if tool_use_id == "tf1" + && content.iter().any(|c| matches!(c, crate::event::ToolResultContent::Text(t) if t.contains("blew up"))))), + "status:failed → ToolResult{{is_error:true}} with output, got {failed:?}" + ); + + // (b) non-zero exitCode → is_error:true (a command that ran but failed). + let nonzero = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"commandExecution","id":"tf2","exitCode":1,"aggregatedOutput":"command not found"},"threadId":"th1","turnId":"t1"}}"#, + ]) + .await; + assert!( + nonzero.iter().any( + |e| matches!(e, SessionEvent::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "tf2") + ), + "non-zero exitCode → ToolResult{{is_error:true}}, got {nonzero:?}" + ); + + // (c) Control: status absent + exitCode 0 → success (is_error:false), so the + // failure cases above are genuine signals, not constants. + let ok = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"commandExecution","id":"ts1","exitCode":0,"aggregatedOutput":"done"},"threadId":"th1","turnId":"t1"}}"#, + ]) + .await; + assert!( + ok.iter().any( + |e| matches!(e, SessionEvent::ToolResult { tool_use_id, is_error: false, .. } if tool_use_id == "ts1") + ), + "exitCode 0 → ToolResult{{is_error:false}}, got {ok:?}" + ); + } + + /// Protocol-audit fix (HIGH): MCP + dynamic-tool OUTPUT must reach ToolResult. + /// Previously only `aggregatedOutput` (a commandExecution-only field) was read, + /// so every mcpToolCall/dynamicToolCall rendered as an EMPTY card = silent data + /// loss. Source-verified shapes (openai/codex v2/item.rs:299/313, mcp.rs:125): + /// mcpToolCall result:{content:[{type:text,text}|{type:image,data,mimeType}], + /// structuredContent} + error:{message}; dynamicToolCall contentItems:[{type: + /// inputText,text}|{type:inputImage,imageUrl}]. + #[tokio::test] + async fn mcp_and_dynamic_tool_output_is_carried_not_dropped() { + // (a) mcpToolCall with text + structuredContent in result. + let mcp = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"mcpToolCall","id":"m1","status":"completed","result":{"content":[{"type":"text","text":"weather: sunny"}],"structuredContent":{"temp":21}}},"threadId":"th1","turnId":"t1"}}"#, + ]) + .await; + let mcp_content = mcp.iter().find_map(|e| match e { + SessionEvent::ToolResult { + tool_use_id, content, .. + } if tool_use_id == "m1" => Some(content.clone()), + _ => None, + }); + let mcp_content = mcp_content.expect("mcpToolCall completed → ToolResult"); + assert!( + mcp_content + .iter() + .any(|c| matches!(c, crate::event::ToolResultContent::Text(t) if t.contains("weather: sunny"))), + "MCP result.content text must reach ToolResult (was dropped), got {mcp_content:?}" + ); + assert!( + mcp_content + .iter() + .any(|c| matches!(c, crate::event::ToolResultContent::Text(t) if t.contains("21"))), + "MCP structuredContent must be carried, got {mcp_content:?}" + ); + + // (b) a FAILED mcpToolCall with error.message (no aggregatedOutput) → the + // cause reaches the red card. + let mcp_err = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"mcpToolCall","id":"m2","status":"failed","error":{"message":"upstream 500"}},"threadId":"th1","turnId":"t1"}}"#, + ]) + .await; + assert!( + mcp_err.iter().any(|e| matches!(e, + SessionEvent::ToolResult { tool_use_id, is_error: true, content, .. } + if tool_use_id == "m2" + && content.iter().any(|c| matches!(c, crate::event::ToolResultContent::Text(t) if t.contains("upstream 500"))))), + "failed mcpToolCall error.message must reach ToolResult, got {mcp_err:?}" + ); + + // (c) dynamicToolCall contentItems (inputText) → carried. + let dyn_call = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"dynamicToolCall","id":"d1","status":"completed","contentItems":[{"type":"inputText","text":"dynamic answer"}]},"threadId":"th1","turnId":"t1"}}"#, + ]) + .await; + assert!( + dyn_call.iter().any(|e| matches!(e, + SessionEvent::ToolResult { tool_use_id, content, .. } + if tool_use_id == "d1" + && content.iter().any(|c| matches!(c, crate::event::ToolResultContent::Text(t) if t.contains("dynamic answer"))))), + "dynamicToolCall contentItems text must reach ToolResult (was dropped), got {dyn_call:?}" + ); + } + + #[tokio::test] + async fn mcp_startup_status_maps_to_provisioning_phases() { + // codex·ACP symmetry (target: claude b_claude_init_captures..._mcp_provisioning): + // the SERVER→CLIENT notification `mcpServer/startupStatus/updated` must map per + // status to the matching ProvisioningPhase, carrying `error` as the reason on + // failure/cancel. Regression guard for the dead-arm bug: codex used to match + // the WRONG prefix `mcpServerStatus` (an outbound request) and hardcode + // ToolsWaiting → a real startup notification produced NO Provisioning. + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"mcpServer/startupStatus/updated","params":{"name":"fs","status":"starting"}}"#, + r#"{"jsonrpc":"2.0","method":"mcpServer/startupStatus/updated","params":{"name":"fs","status":"ready"}}"#, + r#"{"jsonrpc":"2.0","method":"mcpServer/startupStatus/updated","params":{"name":"bad","status":"failed","error":"spawn ENOENT"}}"#, + r#"{"jsonrpc":"2.0","method":"mcpServer/startupStatus/updated","params":{"name":"oauth","status":"cancelled","error":"user aborted"}}"#, + ]) + .await; + let phases: Vec<&ProvisioningPhase> = events + .iter() + .filter_map(|e| match e { + SessionEvent::Provisioning { phase } => Some(phase), + _ => None, + }) + .collect(); + assert_eq!( + phases.len(), + 4, + "one Provisioning per startup notification, got {phases:?}" + ); + assert!( + matches!(phases[0], ProvisioningPhase::ToolsWaiting), + "starting→ToolsWaiting" + ); + assert!(matches!(phases[1], ProvisioningPhase::ToolsReady), "ready→ToolsReady"); + assert!( + matches!(phases[2], ProvisioningPhase::LoadFailed { reason } if reason == "spawn ENOENT"), + "failed→LoadFailed{{reason}} carrying the error text, got {:?}", + phases[2] + ); + assert!( + matches!(phases[3], ProvisioningPhase::Degraded { reason } if reason == "user aborted"), + "cancelled→Degraded{{reason}}, got {:?}", + phases[3] + ); + } + + #[tokio::test] + async fn mcp_oauth_login_failure_maps_to_degraded() { + // success:false (login failed) → Degraded (server up but unauthorized; mirrors + // claude needs-auth → Degraded). success:true carries no FSM signal here. + let failed = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"mcpServer/oauthLogin/completed","params":{"name":"gh","success":false,"error":"token expired"}}"#, + ]) + .await; + assert!( + failed.iter().any(|e| matches!(e, + SessionEvent::Provisioning { phase: ProvisioningPhase::Degraded { reason } } if reason == "token expired")), + "oauth success:false → Degraded{{reason}}, got {failed:?}" + ); + let ok = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"mcpServer/oauthLogin/completed","params":{"name":"gh","success":true}}"#, + ]) + .await; + assert!( + !ok.iter().any(|e| matches!(e, SessionEvent::Provisioning { .. })), + "oauth success:true emits no Provisioning, got {ok:?}" + ); + } + + #[tokio::test] + async fn interrupted_turn_maps_to_cancelled_outcome() { + // ⚠️ REAL ORDERING (M3): codex sends `status→idle` BEFORE + // `turn/completed{status:interrupted}`. The interrupted outcome MUST + // survive — the deferred idle must NOT absorb it into a clean EndTurn. + // turn.status:interrupted → TurnResult{is_error:false, Cancelled} (cancel + // is NOT an error; §C2/O3 + the cancel≠error invariant). + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"thread/status/changed","params":{"threadId":"th1","status":{"type":"idle"}}}"#, + r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"th1","turn":{"id":"t1","status":"interrupted"}}}"#, + ]) + .await; + assert_eq!( + count_turn_results(&events), + 1, + "exactly one terminal (the deferred idle is absorbed by completed), got {events:?}" + ); + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::TurnResult { + is_error: false, + outcome: TurnOutcome::Cancelled { .. }, + .. + } + )), + "interrupted → Cancelled outcome (NOT a clean EndTurn from the leading idle), is_error:false, got {events:?}" + ); + } + + #[tokio::test] + async fn failed_turn_maps_to_error_routing() { + // ⚠️ REAL ORDERING (M3): `status→idle` arrives BEFORE + // `turn/completed{status:failed}`. The error bits MUST survive — if the + // leading idle won, a failed turn would be reported is_error:false and the + // reducer would route it to Idle instead of Error{Backend} (silent failure). + // turn.status:failed → TurnResult{is_error:true} + Failed + error/status. + // ⚠️ C-3: the error uses the REAL codex wire shape — httpStatusCode is + // NESTED inside the externally-tagged codexErrorInfo variant + // ({"httpConnectionFailed":{"httpStatusCode":503}}), NOT a flat top-level + // field. (The old fixture used the flat shape and masked the wrong-path bug.) + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"thread/status/changed","params":{"threadId":"th1","status":{"type":"idle"}}}"#, + r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"th1","turn":{"id":"t1","status":"failed","error":{"message":"server overloaded","codexErrorInfo":{"httpConnectionFailed":{"httpStatusCode":503}}}}}}"#, + ]) + .await; + assert_eq!(count_turn_results(&events), 1, "exactly one terminal, got {events:?}"); + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::TurnResult { + is_error: true, + api_error_status: Some(503), + outcome: TurnOutcome::Failed, + .. + } + )), + "failed → is_error:true + api_error_status:503 (from NESTED codexErrorInfo) + Failed, got {events:?}" + ); + } + + #[tokio::test] + async fn failed_turn_without_http_status_is_none_not_panic() { + // C-3 boundary: a CodexErrorInfo variant WITHOUT httpStatusCode → api_error_status + // None, is_error:true. SHAPE CALIBRATED to schema (was a guessed object): the + // schema (codex-cli/0.137.0/schema-full/ServerNotification.json CodexErrorInfo) + // defines `serverOverloaded` as a BARE STRING enum member (the string oneOf arm: + // contextWindowExceeded/usageLimitExceeded/serverOverloaded/…), NOT an object — + // only httpConnectionFailed/responseStream*/responseTooManyFailedAttempts are + // object-shaped (those carry httpStatusCode). So the real wire is + // `codexErrorInfo:"serverOverloaded"`. The prior `{"serverOverloaded":{}}` object + // wrapper contradicted the schema (contracts README #9). + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"th1","turn":{"id":"t1","status":"failed","error":{"message":"overloaded","codexErrorInfo":"serverOverloaded"}}}}"#, + ]) + .await; + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::TurnResult { + is_error: true, + api_error_status: None, + outcome: TurnOutcome::Failed, + .. + } + )), + "status-less error variant → is_error:true, api_error_status:None, got {events:?}" + ); + } + + /// LIVE-found shape (codex 0.139.0 bad-model turn): a real provider HTTP error + /// collapses `codexErrorInfo` to the bare string `"other"` (no structured + /// httpStatusCode), and the status lives ONLY in the message + /// (`"unexpected status 404 Not Found: …"`). The message-text fallback must lift + /// it so a real provider error still carries its status. Pins the live shape the + /// `codex_live_bad_model_folds_to_error_terminal` test surfaced. + #[tokio::test] + async fn failed_turn_lifts_http_status_from_message_when_codex_error_info_is_other() { + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"th1","turn":{"id":"t1","status":"failed","error":{"message":"unexpected status 404 Not Found: The model 'x' does not exist, request id: req_abc123","codexErrorInfo":"other"}}}}"#, + ]) + .await; + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::TurnResult { + is_error: true, + api_error_status: Some(404), + outcome: TurnOutcome::Failed, + .. + } + )), + "codexErrorInfo:\"other\" + status-in-message → api_error_status lifted from text (404), got {events:?}" + ); + } + + /// The message-status extractor must NOT mis-fire on arbitrary numbers (model + /// ids, request ids, token counts) — only on the anchored `status ` shape. + #[test] + fn extract_http_status_from_message_is_anchored_not_greedy() { + assert_eq!( + extract_http_status_from_message("unexpected status 404 Not Found: req_999"), + Some(404) + ); + assert_eq!( + extract_http_status_from_message("HTTP status 503 from upstream"), + Some(503) + ); + // No "status N" shape → None (not a stray number match). + assert_eq!( + extract_http_status_from_message("the model gpt-5.5 returned 12345 tokens"), + None + ); + assert_eq!(extract_http_status_from_message("request id req_404abc failed"), None); + // Out-of-range "status" number → None (not a real HTTP code). + assert_eq!(extract_http_status_from_message("status 9999 weird"), None); + } + + #[tokio::test] + async fn collab_agent_maps_to_subagent_update() { + // A codex collab/spawned agent item → SubagentUpdate (§6b b1) — the same + // canonical subagent channel claude Task/Workflow uses. The spawn-in-flight + // frame carries an EMPTY agentsStates → fall back to the tool-call id so the + // action still surfaces (Running, no parent yet). + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"item/started","params":{"item":{"type":"collabAgentToolCall","id":"agent-9","model":"o3"},"threadId":"th1","turnId":"t1"}}"#, + ]) + .await; + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::SubagentUpdate { r#ref, status: SubagentStatus::Running, .. } if r#ref == "agent-9" + )), + "collabAgentToolCall (empty agentsStates) → SubagentUpdate keyed by item.id, got {events:?}" + ); + } + + #[tokio::test] + async fn collab_agent_keys_on_child_thread_with_real_status_and_parent() { + // D1: the REAL collab wire shape (collab_spawn_full.jsonl): the completed + // frame carries the spawned child in `agentsStates: {childThreadId -> + // {status}}` + the spawning parent in `senderThreadId`. The roster entry MUST + // be keyed by the CHILD threadId (codex agentId, state.rs:80), carry the + // child's REAL lifecycle status (pendingInit, NOT a coarse completed-bool), + // and the spawn edge (parent_ref = senderThreadId). + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"collabAgentToolCall","id":"call_2","tool":"spawnAgent","status":"completed","senderThreadId":"019eabe9-parent","receiverThreadIds":["019eabea-child"],"model":"openai.gpt-5.5","agentsStates":{"019eabea-child":{"status":"pendingInit","message":null}}},"threadId":"th1","turnId":"t1"}}"#, + ]) + .await; + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::SubagentUpdate { + r#ref, + status: SubagentStatus::PendingInit, + parent_ref: Some(p), + label: Some(l), + } if r#ref == "019eabea-child" && p == "019eabe9-parent" && l == "openai.gpt-5.5" + )), + "real collab frame → SubagentUpdate keyed by CHILD thread, status from agentsStates, parent edge from senderThreadId, got {events:?}" + ); + // The tool-call id (call_2) must NOT leak as a roster ref when a child thread + // is known (else a phantom entry never prunes). + assert!( + !events + .iter() + .any(|e| matches!(e, SessionEvent::SubagentUpdate { r#ref, .. } if r#ref == "call_2")), + "with a known child thread, the tool-call id must not also surface as a roster entry, got {events:?}" + ); + } + + #[test] + fn collab_status_maps_seven_to_six() { + // The 7-state CollabAgentStatus → our 6-state SubagentStatus; notFound folds + // to Shutdown (terminal, prunes), unknown → Running (active, never wedges). + assert!(matches!(map_collab_status("pendingInit"), SubagentStatus::PendingInit)); + assert!(matches!(map_collab_status("running"), SubagentStatus::Running)); + assert!(matches!(map_collab_status("interrupted"), SubagentStatus::Interrupted)); + assert!(matches!(map_collab_status("completed"), SubagentStatus::Completed)); + assert!(matches!(map_collab_status("errored"), SubagentStatus::Errored)); + assert!(matches!(map_collab_status("shutdown"), SubagentStatus::Shutdown)); + assert!( + matches!(map_collab_status("notFound"), SubagentStatus::Shutdown), + "notFound has no 7th state → Shutdown (terminal)" + ); + assert!( + matches!(map_collab_status("someFutureCodexStatus"), SubagentStatus::Running), + "unknown status → Running (active), never a wedged terminal" + ); + } + + /// A FakeAgentIo that emits a `thread/started{thread.id=tid}` (so the reader + /// binds the threadId every `turn/*` dispatch needs) optionally followed by a + /// `turn/started{turn.id=turnid}` (so the active-turn token for steer/interrupt + /// is set), then EOFs cleanly. Clone `captured_stdin()` BEFORE build to assert + /// on the dispatched frames. + fn fake_with_binding(tid: &str, turn_id: Option<&str>) -> FakeAgentIo { + let mut lines = vec![format!( + r#"{{"jsonrpc":"2.0","method":"thread/started","params":{{"thread":{{"id":"{tid}"}}}}}}"# + )]; + if let Some(t) = turn_id { + lines.push(format!( + r#"{{"jsonrpc":"2.0","method":"turn/started","params":{{"threadId":"{tid}","turn":{{"id":"{t}"}}}}}}"# + )); + } + let bytes = format!("{}\n", lines.join("\n")).into_bytes(); + let fake = FakeAgentIo::new( + bytes, + Some(crate::event::ExitStatusLite { + code: Some(0), + signal: None, + }), + ); + fake.release_exit(); + fake + } + + /// Drain the captured-stdin buffer to a String after dispatch settles. Polls + /// briefly because the duplex→capture copy is on a background task. + async fn captured_str(captured: &Arc>>) -> String { + for _ in 0..40 { + let s = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + if !s.trim().is_empty() { + // settle a touch more so the whole frame is flushed + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + return String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + String::new() + } + + #[tokio::test] + async fn dispatch_answer_permission_elicit_writes_action_content_not_decision() { + // An elicit-prefixed request_id → dispatch writes the MCP elicitation body + // `{action, content}` (NOT `{decision}`), keyed by the stripped wire id. + // A plain (un-prefixed) request_id still writes `{decision}`. + let fake = fake_with_binding("th-1", None); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + backend + .dispatch(Command::AnswerPermission { + request_id: format!("{ELICIT_PREFIX}5"), + decision: super::super::types::PermissionDecision::Approved, + selected: None, + answers: vec![], + }) + .await + .expect("accepted"); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""action":"accept""#), + "elicit answer writes {{action}}, got: {written}" + ); + assert!( + written.contains(r#""content":{}"#), + "elicit answer writes a content body, got: {written}" + ); + assert!( + !written.contains(r#""decision""#), + "elicit answer must NOT use the approval {{decision}} body, got: {written}" + ); + assert!( + written.contains(r#""id":5"#), + "keyed by the stripped wire id (numeric), got: {written}" + ); + } + + #[tokio::test] + async fn dispatch_send_writes_turn_start_with_thread_id() { + // dispatch(Send) bumps turn_gen + returns Started, writing the REAL codex + // wire `turn/start{threadId, input:[{type:text,text}]}` (verified against + // the aion-probe transcripts — NOT the fictional `sendUserTurn`). Requires + // the bound threadId, which the fake supplies via thread/started. + let fake = fake_with_binding("th-77", None); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let receipt = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("do it".into())], + metadata: super::super::types::CommandMeta::default(), + }) + .await + .expect("accepted"); + assert_eq!(receipt.turn_gen, 1); + assert_eq!(receipt.admission, Admission::Started); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""method":"turn/start""#), + "wrote turn/start, got: {written}" + ); + assert!( + written.contains(r#""threadId":"th-77""#), + "carries the bound threadId, got: {written}" + ); + assert!( + written.contains("do it"), + "frame carries the prompt text, got: {written}" + ); + assert!( + !written.contains("sendUserTurn"), + "must NOT use the fictional sendUserTurn method" + ); + } + + #[tokio::test] + async fn dispatch_send_during_active_turn_is_noturn_and_opens_no_second_turn_gen() { + use futures_util::StreamExt as _; + // 009 R1c: a flight-period Send (a turn is already active) must NOT open a + // second turn_gen. codex merges the overlapping input into the live turn + // under one turnId; issuing a second turn/start + fetch_add would phantom- + // split one wire turn across two turn_gen buckets downstream. Mirror the + // Cancel arm: accept as NoTurn, write no frame, leave turn_gen unchanged. + let fake = fake_with_binding("th-77", Some("turn-A")); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + // Drive the event stream until the binding's turn/started settles, so + // active_turn_id is populated by the reader BEFORE we dispatch (mirrors + // dispatch_rewind_while_turn_in_flight). + let mut events = backend.events(); + let _ = tokio::time::timeout(std::time::Duration::from_millis(200), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::TurnStarted { .. }) { + break; + } + } + }) + .await; + let gen_before = backend.turn_gen.load(Ordering::SeqCst); + let receipt = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("second prompt".into())], + metadata: super::super::types::CommandMeta::default(), + }) + .await + .expect("accepted"); + assert_eq!( + receipt.admission, + Admission::NoTurn, + "flight-period Send merges, not a new turn" + ); + assert_eq!( + receipt.turn_gen, gen_before, + "turn_gen MUST NOT advance during an active turn (no phantom split)" + ); + assert_eq!( + backend.turn_gen.load(Ordering::SeqCst), + gen_before, + "no fetch_add happened" + ); + let written = captured_str(&captured).await; + assert!( + !written.contains(r#""method":"turn/start""#), + "must NOT write a second turn/start frame, got: {written}" + ); + } + + #[tokio::test] + async fn gap_a_turn_start_response_emits_prompt_accepted_for_client_msg_id() { + // GAP-A: codex's synchronous turn/start RESPONSE (the "accepted" receipt) + // must surface as PromptAccepted{client_msg_id} so the conversation pending + // queue drains. The fake binds the thread (prefix) then, after dispatch + // writes turn/start (rpc id=1, the first id in build_with_io's no-handshake + // path), the gated tail replays the matching response. + let prefix = format!( + "{}\n", + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-a"}}}"# + ) + .into_bytes(); + // response to rpc id=1 (the turn/start), carrying the inProgress turn. + let tail = format!( + "{}\n", + r#"{"jsonrpc":"2.0","id":1,"result":{"turn":{"id":"turn-1","status":"inProgress"}}}"# + ) + .into_bytes(); + let fake = FakeAgentIo::new(prefix, None).with_gated_tail(tail); + let release = fake.stdout_releaser(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let mut events = backend.events(); + + let receipt = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("hi".into())], + metadata: super::super::types::CommandMeta { + client_msg_id: Some("m-7".into()), + ..Default::default() + }, + }) + .await + .expect("accepted"); + assert_eq!(receipt.admission, Admission::Started); + // dispatch wrote turn/start (rpc id=1); now release the matching response. + release(); + + let saw = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::PromptAccepted { ref client_msg_id } if client_msg_id == "m-7") { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!( + saw, + "turn/start response → PromptAccepted{{client_msg_id:m-7}} (drains pending)" + ); + } + + #[tokio::test] + async fn gap_a_no_client_msg_id_means_no_prompt_accepted() { + // A Send WITHOUT a client_msg_id registers no correlation → no PromptAccepted + // (nothing to drain). The turn/start response is then a plain diagnostic. + let prefix = format!( + "{}\n", + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-b"}}}"# + ) + .into_bytes(); + let tail = format!( + "{}\n", + r#"{"jsonrpc":"2.0","id":1,"result":{"turn":{"id":"turn-1","status":"inProgress"}}}"# + ) + .into_bytes(); + let fake = FakeAgentIo::new(prefix, None).with_gated_tail(tail); + let release = fake.stdout_releaser(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let mut events = backend.events(); + backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("hi".into())], + metadata: super::super::types::CommandMeta::default(), // no client_msg_id + }) + .await + .expect("accepted"); + release(); + let saw_pa = tokio::time::timeout(std::time::Duration::from_millis(400), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::PromptAccepted { .. }) { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!(!saw_pa, "no client_msg_id → no PromptAccepted emitted"); + } + + #[tokio::test] + async fn dispatch_cancel_writes_turn_interrupt_with_active_turn_id() { + // Cancel → `turn/interrupt{threadId, turnId}` (hard cancel; the turnId is + // the optimistic token captured from turn/started). + let fake = fake_with_binding("th-9", Some("turn-A")); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + backend + .dispatch(Command::Cancel { + target: CancelTarget::Turn, + }) + .await + .expect("accepted"); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""method":"turn/interrupt""#), + "wrote turn/interrupt, got: {written}" + ); + assert!( + written.contains(r#""threadId":"th-9""#), + "carries threadId, got: {written}" + ); + assert!( + written.contains(r#""turnId":"turn-A""#), + "carries the active turnId, got: {written}" + ); + } + + #[tokio::test] + async fn dispatch_cancel_tool_is_rejected() { + // Tool-scoped cancel is not a codex capability → CommandNotSupported. + let fake = fake_with_binding("th-9", Some("turn-A")); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let err = backend + .dispatch(Command::Cancel { + target: CancelTarget::Tool("call_1".into()), + }) + .await + .expect_err("cancel_tool must be rejected"); + assert!(matches!(err, BackendError::CommandNotSupported { command } if command == "cancel_tool")); + } + + #[tokio::test] + async fn dispatch_rewind_while_turn_in_flight_is_rejected_and_writes_no_frame() { + // G3 idle-gate: a rewind issued WHILE a turn is in flight is rejected and + // writes NO thread/rollback frame (a mid-turn rollback would race the live + // turn's history). turn_in_flight is set by an active turn (turn/started). + let fake = fake_with_binding("th-9", Some("turn-A")); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + // Drive an active turn so turn_in_flight becomes true (the binding fixture's + // turn/started sets it via the reader). + let mut events = backend.events(); + let _ = tokio::time::timeout(std::time::Duration::from_millis(200), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::TurnStarted { .. }) { + break; + } + } + }) + .await; + let err = backend + .dispatch(Command::Rewind { num_turns: 1 }) + .await + .expect_err("mid-turn rewind must be rejected"); + assert!(matches!(err, BackendError::Transport(m) if m.contains("in flight"))); + let written = captured_str(&captured).await; + assert!( + !written.contains("thread/rollback"), + "mid-turn rewind MUST NOT write a thread/rollback frame, got: {written}" + ); + } + + #[tokio::test] + async fn dispatch_rewind_writes_rollback_and_response_maps_to_rewound() { + use futures_util::StreamExt as _; + // G3 down+up: dispatch(Rewind) writes thread/rollback{threadId,numTurns} AND + // the reader maps the response (post-rollback Thread with re-populated turns) + // to a Rewound{to_turn} receipt. to_turn = remaining turn count (T17 anchor). + // The fixture has a bound thread (NO active turn → not in flight) and a gated + // rollback response so dispatch registers the pending id before it lands. + let prefix = format!( + "{}\n", + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-rb"}}}"# + ) + .into_bytes(); + // The rollback response: rpc id 1 (the first id dispatch mints), post-rollback + // thread with 2 surviving turns → to_turn=2. + let tail = format!( + "{}\n", + r#"{"jsonrpc":"2.0","id":1,"result":{"thread":{"id":"th-rb","turns":[{"id":"t-a"},{"id":"t-b"}]}}}"# + ) + .into_bytes(); + let fake = FakeAgentIo::new(prefix, None).with_gated_tail(tail); + let release = fake.stdout_releaser(); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-rb", Box::new(fake)).await; + // Let the reader bind the thread (the prefix) before dispatch. + tokio::time::sleep(std::time::Duration::from_millis(80)).await; + assert!( + backend.capabilities().supported_commands.rewind, + "rewind cap is true (G3)" + ); + + let mut events = backend.events(); + let receipt = backend + .dispatch(Command::Rewind { num_turns: 1 }) + .await + .expect("rewind accepted (cap=true, not in flight)"); + assert_eq!(receipt.admission, Admission::NoTurn); + // The down-leg frame hit the wire. + let written = captured_str(&captured).await; + assert!( + written.contains("thread/rollback") && written.contains(r#""numTurns":1"#), + "wrote thread/rollback with numTurns, got: {written}" + ); + assert!( + written.contains(r#""threadId":"th-rb""#), + "rollback targets the bound thread" + ); + + // Release the gated response; the reader maps it to Rewound{to_turn:2}. + release(); + let to_turn = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if let SessionEvent::Rewound { to_turn } = env.event { + return Some(to_turn); + } + } + None + }) + .await + .ok() + .flatten(); + assert_eq!( + to_turn, + Some(2), + "rollback response (2 surviving turns) → Rewound{{to_turn:2}}" + ); + } + + #[tokio::test] + async fn dispatch_rewind_zero_turns_is_rejected() { + // numTurns must be >= 1 (codex schema); a 0 rewind is rejected without a write. + let fake = fake_with_binding("th-9", None); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let err = backend + .dispatch(Command::Rewind { num_turns: 0 }) + .await + .expect_err("num_turns=0 must be rejected"); + assert!(matches!(err, BackendError::Transport(m) if m.contains(">= 1"))); + let written = captured_str(&captured).await; + assert!(!written.contains("thread/rollback"), "no frame for an invalid rewind"); + } + + #[test] + fn rollback_to_turn_counts_surviving_turns() { + // Shape CALIBRATED to the real wire (protocols/samples/codex-cli/0.139.0/ + // _all_rollback_plan.jsonl): the success result is {thread:{...,turns:[...]}, ...} + // — the key path result.thread.turns[] is confirmed. The first assertion uses a + // trimmed slice of the REAL captured result object (extra thread fields omitted + // for brevity; only the turns path is load-bearing). + let real = serde_json::json!({ + "thread": {"id": "019ef837", "sessionId": "019ef837", "modelProvider": "amazon-bedrock", + "preview": "Reply with exactly: turn-1-ok", "turns": [{"id": "a"}, {"id": "b"}]}, + "model": "gpt-5.5", "modelProvider": "amazon-bedrock", "cwd": "/tmp" + }); + assert_eq!(rollback_to_turn(&real), 2, "to_turn reads result.thread.turns[].len()"); + // ⚠️ LIVE-OBSERVED (codex 0.139.0): a real numTurns:1 rollback returned + // thread.turns = [] → to_turn = 0 even with surviving history. Pinned as the + // honest current behavior (cosmetic only — reducer ignores to_turn). + assert_eq!(rollback_to_turn(&serde_json::json!({"thread":{"turns":[]}})), 0); + // Cross-version fallbacks: flat {turns} + missing turns. + assert_eq!(rollback_to_turn(&serde_json::json!({"turns":[{"id":"a"}]})), 1); + assert_eq!(rollback_to_turn(&serde_json::json!({"thread":{"id":"x"}})), 0); + } + + #[tokio::test] + async fn dispatch_cancel_with_no_active_turn_writes_no_frame() { + // turn/interrupt requires a turnId (non-Option on the wire). With no active + // turn we accept as a no-op WITHOUT writing a frame codex would reject — the + // orchestrator's lowered Cancel folds the FSM to Idle. + let fake = fake_with_binding("th-9", None); // bound thread, NO active turn + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let receipt = backend + .dispatch(Command::Cancel { + target: CancelTarget::Turn, + }) + .await + .expect("accepted as no-op"); + assert_eq!(receipt.admission, Admission::NoTurn); + let written = captured_str(&captured).await; + assert!( + !written.contains("turn/interrupt"), + "no turnId → MUST NOT write a turn/interrupt frame codex rejects, got: {written}" + ); + } + + #[tokio::test] + async fn dispatch_cancel_in_flight_but_unbound_waits_for_bind_then_interrupts() { + // cancel-before-fold (token-burn half): a turn is in flight (dispatch(Send) + // ran, turn_in_flight=true) but the reader has NOT yet bound active_turn_id + // from the async turn/started. A cancel here must NOT silently no-op (codex + // would keep burning tokens) — it polls briefly for the bind, then writes + // turn/interrupt. We simulate the late bind by setting the id from another + // task shortly after the cancel begins waiting. + // thread/started binds the thread but NO turn/started yet (the pre-fold + // window). A GATED TAIL keeps stdout open (never released) so the reader does + // not EOF + clear turn_in_flight. We mark the turn in flight by hand (what + // dispatch(Send) would have done) and bind the active turn id late. + let prefix = format!( + "{}\n", + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-late"}}}"# + ) + .into_bytes(); + let fake = FakeAgentIo::new(prefix, None).with_gated_tail(b"never-released\n".to_vec()); + let captured = fake.captured_stdin(); + let backend = Arc::new(CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await); + backend.bound_thread().await.expect("thread bound from thread/started"); + backend.mark_turn_in_flight_for_test(); + + let binder = backend.clone(); + let late_bind = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(60)).await; + binder.bind_active_turn_for_test("turn-late").await; + }); + + backend + .dispatch(Command::Cancel { + target: CancelTarget::Turn, + }) + .await + .expect("accepted"); + late_bind.await.unwrap(); + + let written = captured_str(&captured).await; + assert!( + written.contains(r#""method":"turn/interrupt""#) && written.contains(r#""turnId":"turn-late""#), + "in-flight cancel waited for the late turn/started bind then interrupted, got: {written}" + ); + } + + #[tokio::test] + async fn config_changed_emitted_on_thread_settings_updated() { + // C6 §6: thread/settings/updated → ConfigChanged (the non-optimistic confirmation), + // NOT AdapterSpecific. Carries model + the permission tier read from + // `activePermissionProfile.id`. The colon wire id is mapped to the legacy bare token + // the catalog/frontend uses (`:danger-full-access` → `full-access`) so the picker + // highlights the matching catalog entry and all locales key off the same value the + // legacy ACP path presented. Verbatim 0.139.0 shape (the frame ALSO carries + // collaborationMode, which we must ignore). + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"thread/settings/updated","params":{"threadId":"th1","threadSettings":{"model":"gpt-5.5","activePermissionProfile":{"id":":danger-full-access","extends":null},"collaborationMode":{"mode":"default","settings":{"model":"gpt-5.5"}}}}}"#, + ]) + .await; + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::ConfigChanged { model: Some(m), mode: Some(md) } if m == "gpt-5.5" && md == "full-access" + )), + "thread/settings/updated → ConfigChanged{{model, mode=legacy bare token of activePermissionProfile.id}}, got {events:?}" + ); + } + + #[tokio::test] + async fn config_changed_mode_none_when_permission_profile_null() { + // feature 012: when the tier was set via the raw sandboxPolicy channel (not our + // permissions path), `activePermissionProfile` is null → carry no mode (keep the + // last-known selection) rather than clobber it. We must NOT fall back to + // collaborationMode.mode. + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"thread/settings/updated","params":{"threadId":"th1","threadSettings":{"model":"gpt-5.5","activePermissionProfile":null,"collaborationMode":{"mode":"default","settings":{"model":"gpt-5.5"}}}}}"#, + ]) + .await; + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::ConfigChanged { model: Some(m), mode: None } if m == "gpt-5.5" + )), + "null activePermissionProfile → ConfigChanged with mode:None, got {events:?}" + ); + } + + #[tokio::test] + async fn dispatch_steer_writes_turn_steer_with_expected_turn_id() { + // R6 Steer → `turn/steer{threadId, expectedTurnId, input}` (soft injection; + // NoTurn admission — no new turn_gen). The expectedTurnId is the active turn. + let fake = fake_with_binding("th-3", Some("turn-X")); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let receipt = backend + .dispatch(Command::Steer { + content: vec![ContentBlock::Text("STEERED".into())], + }) + .await + .expect("accepted"); + assert_eq!(receipt.admission, Admission::NoTurn, "steer folds into the live turn"); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""method":"turn/steer""#), + "wrote turn/steer, got: {written}" + ); + assert!( + written.contains(r#""expectedTurnId":"turn-X""#), + "gated by the active turn token, got: {written}" + ); + assert!(written.contains("STEERED"), "carries the steer text, got: {written}"); + } + + #[tokio::test] + async fn dispatch_steer_without_active_turn_is_rejected() { + // No active turn → nothing to inject into → reject (matches codex's + // activeTurnNotSteerable; we pre-empt the wire roundtrip). + let fake = fake_with_binding("th-3", None); // bound thread but NO active turn + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let err = backend + .dispatch(Command::Steer { + content: vec![ContentBlock::Text("late".into())], + }) + .await + .expect_err("steer with no active turn must be rejected"); + assert!(matches!(err, BackendError::Transport(m) if m.contains("no active turn"))); + } + + #[tokio::test] + async fn dispatch_set_model_writes_thread_settings_update() { + // R6 SetModel → `thread/settings/update{threadId, model}` (verified frame). + let fake = fake_with_binding("th-5", None); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + backend + .dispatch(Command::SetModel { + model: "gpt-5.5".into(), + }) + .await + .expect("accepted"); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""method":"thread/settings/update""#), + "wrote thread/settings/update, got: {written}" + ); + assert!( + written.contains(r#""model":"gpt-5.5""#), + "carries the model, got: {written}" + ); + assert!( + written.contains(r#""threadId":"th-5""#), + "carries threadId, got: {written}" + ); + } + + #[tokio::test] + async fn dispatch_set_config_option_effort_writes_thread_settings_update() { + // codex's reasoning effort is a first-class `thread/settings/update{threadId, + // effort}` field (schema: ThreadSettingsUpdateParams.effort → ReasoningEffort), + // so SetConfigOption{effort} routes through the same wire as SetModel/SetMode — + // NOT a CommandNotSupported reject. Each effort alias id maps to the `effort` key. + for option_id in ["effort", "reasoning_effort", "thought_level"] { + let fake = fake_with_binding("th-7", None); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + backend + .dispatch(Command::SetConfigOption { + option_id: option_id.into(), + value: "high".into(), + }) + .await + .unwrap_or_else(|e| panic!("effort id `{option_id}` must be accepted, got: {e:?}")); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""method":"thread/settings/update""#), + "id `{option_id}` wrote thread/settings/update, got: {written}" + ); + assert!( + written.contains(r#""effort":"high""#), + "id `{option_id}` carries the effort value, got: {written}" + ); + assert!( + written.contains(r#""threadId":"th-7""#), + "id `{option_id}` carries threadId, got: {written}" + ); + } + } + + #[tokio::test] + async fn dispatch_set_config_option_non_effort_still_rejects() { + // Only the effort aliases have a codex wire; any other generic config option + // still has none and must reject with CommandNotSupported (not silently drop). + let fake = fake_with_binding("th-8", None); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let err = backend + .dispatch(Command::SetConfigOption { + option_id: "verbosity".into(), + value: "high".into(), + }) + .await + .expect_err("a non-effort config option must reject"); + assert!( + matches!( + err, + BackendError::CommandNotSupported { + command: "set_config_option" + } + ), + "got: {err:?}" + ); + } + + #[tokio::test] + async fn dispatch_set_mode_writes_permissions_profile_id() { + // SetMode → `thread/settings/update{threadId, permissions}` where `permissions` is + // the DISCOVERED colon-prefixed profile id. A colon id passes through verbatim; a + // legacy bare value (`full-access`) normalizes onto its colon id. NOT the old + // collaborationMode object, and NOT a bare id (codex rejects a colon-less id). + // Needs no known model. + let fake = fake_with_binding("th-6", None); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + // A discovered colon id flows through unchanged (legacy-ACP parity: the wire id IS + // the selector value). + backend + .dispatch(Command::SetMode { + mode: ":danger-full-access".into(), + }) + .await + .expect("accepted"); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""method":"thread/settings/update""#), + "wrote thread/settings/update, got: {written}" + ); + assert!( + written.contains(r#""permissions":":danger-full-access""#), + "carries the discovered colon-prefixed profile id verbatim, got: {written}" + ); + assert!( + !written.contains(r#""collaborationMode""#), + "must NOT send the old collaborationMode object, got: {written}" + ); + assert!( + !written.contains(r#""permissions":"danger-full-access""#), + "must NOT strip the mandatory leading colon, got: {written}" + ); + } + + #[tokio::test] + async fn dispatch_set_mode_normalizes_legacy_persisted_value() { + // A legacy persisted BARE value (`yolo`) — from an older AionUi that stored the + // pre-discovery alias — normalizes onto the `:danger-full-access` colon id, so an + // upgrading user's stored mode applies straight through with zero fallback. This is + // the codex analogue of legacy ACP `normalize_requested_mode` (alias → native id). + let fake = fake_with_binding("th-6", None); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + backend + .dispatch(Command::SetMode { mode: "yolo".into() }) + .await + .expect("accepted"); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""permissions":":danger-full-access""#), + "legacy `yolo` normalizes to :danger-full-access, got: {written}" + ); + } + + #[tokio::test] + async fn dispatch_set_mode_passes_custom_profile_id_verbatim() { + // A user `[permissions.]` custom profile — discovered via permissionProfile/list + // and NOT one of the built-in tiers — must reach the wire UNCHANGED. This is the + // heart of the legacy-ACP parity: codex owns the value set, AionCore only transports + // it (no fixed-enum whitelist that would drop a custom profile). + let fake = fake_with_binding("th-6", None); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + backend + .dispatch(Command::SetMode { + mode: ":team-review".into(), + }) + .await + .expect("accepted"); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""permissions":":team-review""#), + "custom profile id reaches the wire verbatim, got: {written}" + ); + } + + #[tokio::test] + async fn set_model_error_response_surfaces_notice_not_silently_dropped() { + use futures_util::StreamExt as _; + + // GAP (codex analogue of the acp_conn fix): dispatch(SetMode/SetModel) writes a + // `thread/settings/update` request but used to register its rpc id in NO pending + // map, so a JSON-RPC ERROR response (codex rejecting an invalid model/mode) was + // claimed by no one and silently dropped — the user never saw the failed set. + // Now `pending_set` carries "model→" so the reader surfaces the error as a + // Notice{Warning} (and does NOT emit a second ConfigChanged: success converges + // via thread/settings/updated). Feed an error response keyed to the registered id. + let err_resp = r#"{"jsonrpc":"2.0","id":42,"error":{"code":-32602,"message":"model not found"}}"#; + let bytes = format!("{err_resp}\n").into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); + let backend = CodexSessionBackend::build_with_io("codex-set", Box::new(fake)).await; + // Register the pending id the reader will claim (dispatch does this; here we mimic + // it so the response — keyed 42 — is recognized as a SetModel reconcile target). + backend.set_pending_set_for_test(42, "model\u{2192}gpt-bogus").await; + // Subscribe BEFORE the reader consumes the response (broadcast drops pre-subscribe). + let mut events = backend.events(); + let mut notice = None; + let mut saw_config_changed = false; + for _ in 0..40 { + match tokio::time::timeout(std::time::Duration::from_millis(25), events.next()).await { + Ok(Some(env)) => match env.event { + SessionEvent::Notice { level, message } => { + notice = Some((level, message)); + break; + } + SessionEvent::ConfigChanged { .. } => saw_config_changed = true, + _ => {} + }, + _ => continue, + } + } + let (level, message) = notice.expect("a rejected set surfaces a Notice, not a silent drop"); + assert!( + matches!(level, crate::event::NoticeLevel::Warning), + "rejected set is a Warning, got {level:?}" + ); + assert!( + message.contains("model\u{2192}gpt-bogus") && message.contains("model not found"), + "Notice carries the label + the agent's error message, got: {message}" + ); + assert!( + !saw_config_changed, + "an ERROR response must NOT emit a ConfigChanged (no false convergence)" + ); + } + + #[tokio::test] + async fn dispatch_set_mode_obsolete_and_unknown_bare_values_fall_to_workspace() { + // Legacy-ACP parity: AionCore never ships a frame codex would reject. Any unknown + // or obsolete BARE value normalizes to the safe `:workspace` tier rather than + // erroring. `plan` (the OLD collaborationMode token) is the key case — it no longer + // collaboration-maps but lands on the workspace profile, proving the + // collaborationMode axis is gone. (Validation of a stale DISCOVERED colon id is the + // reconcile/reader's job against the live catalog, not this normalize step.) + let fake = fake_with_binding("th-6", None); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + backend + .dispatch(Command::SetMode { mode: "plan".into() }) + .await + .expect("plan normalizes to :workspace, accepted"); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""permissions":":workspace""#), + "the obsolete `plan` token normalizes to the default workspace profile, got: {written}" + ); + } + + #[tokio::test] + async fn dispatch_answer_auth_writes_keyed_refresh_response() { + // R6/R15 AnswerAuth answers a PENDING `account/chatgptAuthTokens/refresh` + // reverse-RPC by writing the keyed JSON-RPC RESPONSE carrying the supplied + // tokens (NOT a fresh account/login/start). The fake first emits the refresh + // request (id=99) so the reader stashes pending_auth_id, then stays alive so + // we can capture the response dispatch writes. + let bytes = format!( + "{}\n", + r#"{"jsonrpc":"2.0","id":99,"method":"account/chatgptAuthTokens/refresh","params":{"reason":"expired"}}"# + ) + .into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + // Wait for the reader to surface Permission{Auth} (pending_auth_id set). + { + let mut events = backend.events(); + let saw_auth = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if matches!( + env.event, + SessionEvent::Permission { + kind: PermissionKind::Auth, + .. + } + ) { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!(saw_auth, "refresh must surface as Permission(Auth) before answering"); + } + backend + .dispatch(Command::AnswerAuth { + method_id: "chatgptAuthTokens".into(), + credentials: json!({ "accessToken": "jwt-abc", "chatgptAccountId": "acct-1" }), + }) + .await + .expect("accepted"); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""id":99"#), + "response keyed to the refresh request id, got: {written}" + ); + assert!( + written.contains(r#""result""#), + "wrote a JSON-RPC result (the answer), got: {written}" + ); + // camelCase = the only schema-evidenced shape (ChatgptAuthTokensLoginAccountParams); + // ⚠️ unverified against a live re-auth capture (codex defines no refresh-response + // schema) — see the dispatch impl tripwire. Was snake_case (contradicted all evidence). + assert!( + written.contains(r#""accessToken":"jwt-abc""#), + "carries the access token (camelCase, schema-evidenced), got: {written}" + ); + assert!( + written.contains(r#""chatgptAccountId":"acct-1""#), + "carries the account id (camelCase, schema-evidenced), got: {written}" + ); + } + + #[tokio::test] + async fn dispatch_answer_auth_without_pending_is_rejected() { + // AnswerAuth with no pending refresh → reject (nothing to answer). + let fake = fake_with_binding("th-8", None); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let err = backend + .dispatch(Command::AnswerAuth { + method_id: "chatgptAuthTokens".into(), + credentials: json!({ "accessToken": "x" }), + }) + .await + .expect_err("no pending auth → reject"); + assert!(matches!(err, BackendError::Transport(m) if m.contains("no pending auth"))); + } + + #[tokio::test] + async fn server_request_resolved_maps_to_permission_resolved() { + // serverRequest/resolved → PermissionResolved so the reducer decrements the + // matching waiting_on_* counter (R9/R15). Default kind=Tool (the common + // approval case) when no pending auth id matches. + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"serverRequest/resolved","params":{"threadId":"th1","request_id":7}}"#, + ]) + .await; + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::PermissionResolved { + kind: PermissionKind::Tool, + .. + } + )), + "serverRequest/resolved → PermissionResolved(Tool), got {events:?}" + ); + } + + #[tokio::test] + async fn dispatch_acknowledge_is_local_noop() { + // Acknowledge has NO codex wire (folds at the conversation layer). Accept + // as a no-op; assert nothing was written to stdin. + let fake = fake_with_binding("th-2", None); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let receipt = backend + .dispatch(Command::Acknowledge { + node_id: "node-1".into(), + }) + .await + .expect("accepted"); + assert_eq!(receipt.admission, Admission::NoTurn); + let written = captured_str(&captured).await; + assert!( + written.is_empty() || !written.contains(r#""method""#), + "Acknowledge writes no client request, got: {written}" + ); + } + + // ===== R8 dual-terminal reconcile: EXACTLY ONE TurnResult per turn ===== + + fn count_turn_results(events: &[SessionEvent]) -> usize { + events + .iter() + .filter(|e| matches!(e, SessionEvent::TurnResult { .. })) + .count() + } + + /// R8 — turn/completed arrives FIRST, then status→idle. Exactly one + /// TurnResult (the rich one from turn/completed); the trailing idle is absorbed. + #[tokio::test] + async fn r8_completed_then_idle_yields_one_turn_result() { + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"th1","turn":{"id":"t1","status":"completed"}}}"#, + r#"{"jsonrpc":"2.0","method":"thread/status/changed","params":{"threadId":"th1","status":{"type":"idle"}}}"#, + ]) + .await; + assert_eq!( + count_turn_results(&events), + 1, + "completed→idle must converge to ONE TurnResult, got {events:?}" + ); + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::TurnResult { + is_error: false, + outcome: TurnOutcome::Completed { .. }, + .. + } + )), + "the one TurnResult is the rich Completed from turn/completed" + ); + } + + /// R8/M3 — the REAL codex ordering: status→idle arrives FIRST, then the + /// authoritative turn/completed. The idle DEFERS (emits nothing); completed + /// produces the one rich terminal. Exactly one TurnResult. + #[tokio::test] + async fn r8_idle_first_then_completed_yields_one_turn_result() { + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"thread/status/changed","params":{"threadId":"th1","turnId":"t1","status":{"type":"idle"}}}"#, + r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"th1","turn":{"id":"t1","status":"completed"}}}"#, + ]) + .await; + assert_eq!( + count_turn_results(&events), + 1, + "idle-first defers; the authoritative completed produces the ONE TurnResult, got {events:?}" + ); + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::TurnResult { + outcome: TurnOutcome::Completed { .. }, + .. + } + )), + "the terminal is the rich Completed from turn/completed, not a synthesized fallback" + ); + } + + /// R8 — status→idle with NO turn/completed at all (a dropped/missing terminal). + /// The deferred idle is flushed as a clean terminal at EOF so the FSM doesn't + /// hang Running forever (defensive — not observed in real codex). + #[tokio::test] + async fn r8_idle_alone_still_terminates() { + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"thread/status/changed","params":{"threadId":"th1","status":{"type":"idle"}}}"#, + ]) + .await; + assert_eq!( + count_turn_results(&events), + 1, + "a bare status→idle (no turn/completed) MUST still produce one terminal (EOF flush), got {events:?}" + ); + } + + /// R8/M3 — TWO turns on ONE connection (the real codex shape: app-server is + /// persistent, multi-turn). Each turn must terminate EXACTLY once. The + /// `terminated`/`idle_pending` flags MUST reset on the second turn/started — + /// otherwise the second turn's terminal is absorbed and its FSM hangs Running. + #[tokio::test] + async fn r8_two_turns_each_terminate_once() { + let events = drive_codex(&[ + // turn 1: started → idle → completed + r#"{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"th1","turn":{"id":"t1"}}}"#, + r#"{"jsonrpc":"2.0","method":"thread/status/changed","params":{"threadId":"th1","status":{"type":"idle"}}}"#, + r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"th1","turn":{"id":"t1","status":"completed"}}}"#, + // turn 2: started → idle → completed (failed this time) + r#"{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"th1","turn":{"id":"t2"}}}"#, + r#"{"jsonrpc":"2.0","method":"thread/status/changed","params":{"threadId":"th1","status":{"type":"idle"}}}"#, + r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"th1","turn":{"id":"t2","status":"failed","error":{"message":"boom","codexErrorInfo":{"httpConnectionFailed":{"httpStatusCode":500}}}}}}"#, + ]) + .await; + assert_eq!( + count_turn_results(&events), + 2, + "each of the two turns terminates exactly once (reset on turn/started), got {events:?}" + ); + // The second turn's failure outcome must survive (not be absorbed). + assert!( + events.iter().any(|e| matches!( + e, + SessionEvent::TurnResult { + is_error: true, + api_error_status: Some(500), + .. + } + )), + "the second turn's failed status survives, got {events:?}" + ); + } + + /// R8 — a non-idle status change is advisory: produces NO TurnResult. + #[tokio::test] + async fn r8_active_status_is_advisory_no_terminal() { + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"thread/status/changed","params":{"threadId":"th1","status":{"type":"active","activeFlags":[]}}}"#, + ]) + .await; + assert_eq!(count_turn_results(&events), 0, "active status is advisory, no terminal"); + } + + /// B1 (BLOCKER regression): the `initialize` handshake MUST opt into the + /// experimental API — `capabilities.experimentalApi:true`, NESTED (top-level is + /// silently ignored by codex). Without it, thread/settings/update (SetMode/ + /// SetModel) + thread/turns/list (ListCheckpoints) are rejected `invalid_request` + /// by real codex. Asserts the exact frame shape (no live process needed). + #[test] + fn b1_initialize_frame_opts_into_experimental_api_nested() { + let frame = initialize_params().into_frame(1, "initialize"); + assert_eq!(frame["method"], "initialize"); + // NESTED under capabilities — the schema-required location. + assert_eq!( + frame["params"]["capabilities"]["experimentalApi"], + serde_json::Value::Bool(true), + "experimentalApi MUST be true and nested under capabilities, got: {frame}" + ); + // A top-level experimentalApi would be silently ignored → must NOT rely on it. + assert!( + frame["params"].get("experimentalApi").is_none(), + "experimentalApi must NOT be top-level (codex ignores it there)" + ); + assert_eq!(frame["params"]["clientInfo"]["name"], "aionui-session"); + } + + /// thread/start params thread cwd from config; approvalPolicy/sandbox are valid + /// codex enum values. MODEL IS NEVER EMBEDDED (codex-model-gating regression fix): + /// the model binds the whole thread and cannot be validated at this instant + /// (model/list comes AFTER thread/start), so a requested model is applied later + /// via a validated `SetModel` (`reconcile_codex_model`), never here. + #[test] + fn thread_start_frame_threads_cwd_and_never_model() { + let frame = thread_start_params(&SessionConfig { + cwd: Some("/work".into()), + model: Some("gpt-5.5".into()), + ..Default::default() + }) + .into_frame(2, "thread/start"); + assert_eq!(frame["method"], "thread/start"); + assert_eq!(frame["params"]["cwd"], "/work"); + assert!( + frame["params"].get("model").is_none(), + "model must NOT be bound at thread/start even when config carries one \ + (applied post-discovery via a validated SetModel instead)" + ); + assert_eq!(frame["params"]["approvalPolicy"], "on-request"); + assert_eq!(frame["params"]["sandbox"], "workspace-write"); + // omitted when config has neither + let bare = thread_start_params(&SessionConfig::default()).into_frame(3, "thread/start"); + assert!(bare["params"].get("cwd").is_none()); + assert!(bare["params"].get("model").is_none()); + // Wave 0c: a default (empty) init carries NO config/baseInstructions — the + // pre-0c thread/start is byte-identical for conversations with no MCP/preset. + assert!( + bare["params"].get("config").is_none(), + "empty init → no config override" + ); + assert!(bare["params"].get("baseInstructions").is_none()); + } + + /// G1-A: thread/start serializes SessionConfig.sandbox_mode data-driven — + /// `None` keeps the safe `workspace-write` default; a resolved policy (e.g. a + /// yolo agent → `danger-full-access`) rides the wire. The default path stays + /// byte-identical (asserted by the test above). + #[test] + fn thread_start_sandbox_is_data_driven_from_config() { + let frame = thread_start_params(&SessionConfig { + sandbox_mode: Some("danger-full-access".into()), + ..Default::default() + }) + .into_frame(2, "thread/start"); + assert_eq!( + frame["params"]["sandbox"], "danger-full-access", + "a resolved sandbox_mode overrides the workspace-write default" + ); + // approvalPolicy keeps its default when only sandbox_mode is set. + assert_eq!(frame["params"]["approvalPolicy"], "on-request"); + // Restriction rides the SAME axis: a read-only conversation seeds + // sandbox:"read-only" at thread/start so the FIRST turn is already locked + // down (the SetMode permission profile applies only on the NEXT turn). This + // is the wire half of the read-only first-turn-write regression fix. + let ro = thread_start_params(&SessionConfig { + sandbox_mode: Some("read-only".into()), + ..Default::default() + }) + .into_frame(3, "thread/start"); + assert_eq!( + ro["params"]["sandbox"], "read-only", + "a read-only conversation must launch its thread under the read-only sandbox" + ); + } + + /// thread/start serializes SessionConfig.approval_policy data-driven (sibling of + /// sandbox) — `None` keeps the safe `on-request` default; a resolved policy + /// (e.g. a yolo agent → `never`) rides the wire. The default path stays + /// byte-identical. + #[test] + fn thread_start_approval_policy_is_data_driven_from_config() { + // default: None ⇒ on-request (byte-identical to pre-data-driven handshake) + let default_frame = thread_start_params(&SessionConfig::default()).into_frame(1, "thread/start"); + assert_eq!(default_frame["params"]["approvalPolicy"], "on-request"); + // resolved: a yolo agent runs unattended → never + let frame = thread_start_params(&SessionConfig { + approval_policy: Some("never".into()), + ..Default::default() + }) + .into_frame(2, "thread/start"); + assert_eq!( + frame["params"]["approvalPolicy"], "never", + "a resolved approval_policy overrides the on-request default" + ); + // sandbox keeps its default when only approval_policy is set. + assert_eq!(frame["params"]["sandbox"], "workspace-write"); + } + + /// Wave 0c: MCP servers reach codex via `config.mcp_servers` (a MAP keyed by + /// name — NOT a per-thread array like ACP), and the preset goes to + /// `baseInstructions`. The codex stdio shape uses `env` as a MAP (verified live + /// against codex 0.139.0 + `codex mcp add` TOML output). + #[test] + fn thread_start_injects_codex_mcp_map_and_preset() { + use crate::backend::{McpServerSpec, McpTransport, SessionInit}; + let frame = thread_start_params(&SessionConfig { + cwd: Some("/work".into()), + init: SessionInit { + mcp_servers: vec![ + McpServerSpec { + name: "fs".into(), + transport: McpTransport::Stdio { + command: "/usr/bin/node".into(), + args: vec!["s.js".into()], + env: vec![("TOKEN".into(), "x".into())], + }, + }, + McpServerSpec { + name: "remote".into(), + transport: McpTransport::Http { + url: "https://mcp.example/api".into(), + headers: vec![], + }, + }, + ], + preset_context: Some("You are a helpful assistant.".into()), + ..Default::default() + }, + ..Default::default() + }) + .into_frame(2, "thread/start"); + // MCP is a MAP under config.mcp_servers, keyed by name. + let mcp = &frame["params"]["config"]["mcp_servers"]; + assert_eq!(mcp["fs"]["command"], "/usr/bin/node"); + assert_eq!(mcp["fs"]["args"][0], "s.js"); + // codex env is a MAP {KEY:VAL}, NOT acp's array of {name,value}. + assert_eq!(mcp["fs"]["env"]["TOKEN"], "x"); + assert_eq!(mcp["remote"]["url"], "https://mcp.example/api"); + // preset → baseInstructions. + assert_eq!(frame["params"]["baseInstructions"], "You are a helpful assistant."); + } + + /// R4 live spawn: open_session MUST route through the INJECTED Spawner (S14, + /// never raw-spawn) with the right CommandSpec (`codex app-server`, cwd + any + /// extra_args). FakeSpawner records the spec then Errs (it can't synthesize a + /// real ManagedProcess), so we assert the SPEC, not a live process — the real + /// handshake end-to-end is a real-machine concern (Bedrock AWS_PROFILE=pionex). Mirrors + /// the 002 T14 "routes through injected spawner" discipline. + #[tokio::test] + async fn r4_open_session_routes_through_injected_spawner_with_codex_app_server() { + use crate::testing::FakeSpawner; + let spawner = std::sync::Arc::new(FakeSpawner::new()); + let conn = CodexConnection::new(spawner.clone()); + let res = conn + .open_session( + SessionSpec::Fresh { + session_id: "logical-1".into(), + }, + SessionConfig { + cwd: Some("/tmp/work".into()), + extra_args: vec!["--flag".into()], + ..Default::default() + }, + ) + .await; + // FakeSpawner can't make a real process → open_session Errs at spawn. + assert!(res.is_err(), "FakeSpawner cannot synthesize a process → spawn Errs"); + assert_eq!( + spawner.call_count(), + 1, + "open_session routed through the injected spawner exactly once" + ); + let spec = spawner.last_command().await.expect("a CommandSpec was recorded"); + assert_eq!(spec.command.to_str(), Some("codex"), "spawns the codex binary"); + assert_eq!( + spec.args.first().map(String::as_str), + Some("app-server"), + "first arg is app-server" + ); + assert!( + spec.args.iter().any(|a| a == "--flag"), + "extra_args threaded into the spawn" + ); + assert_eq!(spec.cwd.as_deref(), Some("/tmp/work"), "cwd threaded (workspace)"); + } + + // ===== B-CODEX-MODEL-LIST: discovery (model/list + permissionProfile/list) ===== + + /// feature 012 (R1): the handshake MUST discover `permissionProfile/list` (codex's + /// mode axis IS its permission axis) and MUST NOT send `collaborationMode/list` + /// (plan/default has no UI entry — matches legacy ACP). Drives a real `run_handshake` + /// against captured stdin and asserts the wire. + #[tokio::test] + async fn handshake_discovers_permission_profiles_not_collaboration_mode() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-hs", Box::new(fake)).await; + backend.run_handshake(None).await.expect("handshake writes"); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""method":"model/list""#), + "handshake discovers model/list, got: {written}" + ); + assert!( + written.contains(r#""method":"permissionProfile/list""#), + "feature 012: handshake discovers permissionProfile/list, got: {written}" + ); + assert!( + !written.contains(r#""method":"collaborationMode/list""#), + "feature 012: handshake must NOT send collaborationMode/list, got: {written}" + ); + } + + #[tokio::test] + async fn b_codex_model_list_response_fills_discovered_and_capabilities() { + // CALIBRATED TO THE REAL WIRE (README discipline #9 / dimension 25): the + // response shape below is copied from the live capture + // protocols/samples/codex-cli/0.137.0/appserver-methods/catalog.jsonl + // (id:5 model/list, id:7 collaborationMode/list) — NOT a hand-written guess. + // Both lists live under `result.data[]`; supportedReasoningEfforts is an + // array of OBJECTS {reasoningEffort, description}; a mode item carries both a + // display `name` ("Plan") and the lowercase `mode` token ("plan") that + // SetMode actually sends. The prior fixture used result.models[]/result.modes[] + // + bare-string efforts — a self-confirming shape that never matched the wire, + // so model discovery silently produced an empty list → Bedrock 404. + let model_resp = r#"{"jsonrpc":"2.0","id":50,"result":{"data":[{"id":"openai.gpt-5.5","model":"openai.gpt-5.5","displayName":"GPT-5.5","description":"Frontier model","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"low","description":"Fast"},{"reasoningEffort":"medium","description":"Balanced"},{"reasoningEffort":"high","description":"Deep"},{"reasoningEffort":"xhigh","description":"Deepest"}],"defaultReasoningEffort":"medium","isDefault":true},{"id":"openai.gpt-5.4","model":"openai.gpt-5.4","displayName":"gpt-5.4","isDefault":false}],"nextCursor":null}}"#; + // feature 012: codex's mode catalog is the permission-profile list mapped to the + // fixed enum (NOT collaborationMode). Verbatim 0.139.0 permissionProfile/list shape. + let perm_resp = r#"{"jsonrpc":"2.0","id":51,"result":{"data":[{"id":":read-only","description":null},{"id":":workspace","description":null},{"id":":danger-full-access","description":null}],"nextCursor":null}}"#; + let bytes = format!("{model_resp}\n{perm_resp}\n").into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + // Register the pending discovery ids the reader will claim (open_session does + // this after the handshake; build_with_io skips the handshake). + { + let mut pd = backend.pending_discovery.lock().await; + pd.insert(50, DiscoveryKind::Models); + pd.insert(51, DiscoveryKind::Permissions); + } + // Subscribe to drive the reader; it consumes the two responses → fill_discovery. + let _events = backend.events(); + // Poll capabilities() until the merge lands (reader runs async). + let mut caps = backend.capabilities(); + for _ in 0..40 { + if !caps.available_models.is_empty() && !caps.available_modes.is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + caps = backend.capabilities(); + } + assert_eq!( + caps.available_models.len(), + 2, + "model/list result.data[] filled available_models, got {caps:?}" + ); + assert!( + caps.available_models + .iter() + .any(|m| m.id == "openai.gpt-5.5" && m.name == "GPT-5.5"), + "model id+displayName mapped from real wire, got {:?}", + caps.available_models + ); + assert!( + caps.available_models.iter().any(|m| m.reasoning_efforts + == vec![ + "low".to_string(), + "medium".to_string(), + "high".to_string(), + "xhigh".to_string() + ]), + "supportedReasoningEfforts OBJECT array → reasoningEffort tokens, got {:?}", + caps.available_models + ); + // The three built-in permission tiers surface as the LEGACY bare tokens the old ACP + // path advertised (`:workspace` → `auto`), in discovery order, so the picker's i18n + // keys off the same value it always did. `normalize_to_profile_id` is the inverse on + // the return trip; a custom profile (none here) would keep its colon id verbatim. + assert_eq!( + caps.available_modes.iter().map(|m| m.id.as_str()).collect::>(), + vec!["read-only", "auto", "full-access"], + "permissionProfile/list built-ins → legacy bare tokens, got {:?}", + caps.available_modes + ); + } + + /// The FIX (async catalog-arrival signal): each `model/list` / + /// `collaborationMode/list` RESPONSE must BROADCAST a `CatalogUpdated` carrying the + /// current `discovered` snapshot — before this the parser silently filled the cache + /// with no upward signal, so the frontend (which read an empty `config_options` on + /// open) never re-fetched and the model selector stayed disabled. The two responses + /// are SEPARATE, so we assert a snapshot arrives that carries BOTH lists (the second + /// arrival refines the first). + #[tokio::test] + async fn model_list_response_broadcasts_catalog_updated() { + use futures_util::StreamExt as _; + let model_resp = r#"{"jsonrpc":"2.0","id":50,"result":{"data":[{"id":"openai.gpt-5.5","displayName":"GPT-5.5"}],"nextCursor":null}}"#; + // codex's modes come from permissionProfile/list (colon ids on the wire); the + // built-in `:workspace` tier surfaces to the frontend as the legacy bare token `auto`. + let perm_resp = + r#"{"jsonrpc":"2.0","id":51,"result":{"data":[{"id":":workspace","description":null}],"nextCursor":null}}"#; + let bytes = format!("{model_resp}\n{perm_resp}\n").into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + { + let mut pd = backend.pending_discovery.lock().await; + pd.insert(50, DiscoveryKind::Models); + pd.insert(51, DiscoveryKind::Permissions); + } + let mut events = backend.events(); + // Collect CatalogUpdated events until one carries both a model and a mode (the + // second, refining, snapshot) — or time out. + let mut saw_both = false; + for _ in 0..80 { + if let Ok(Some(env)) = tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await + && let SessionEvent::CatalogUpdated { models, modes, .. } = env.event + && !models.is_empty() + && !modes.is_empty() + { + assert_eq!(models[0].id, "openai.gpt-5.5"); + assert_eq!( + modes[0].id, "auto", + ":workspace surfaces as the legacy bare token `auto`" + ); + saw_both = true; + break; + } + } + assert!( + saw_both, + "a CatalogUpdated snapshot carrying both the model and the mode must be broadcast" + ); + } + + /// Cross-version fallback (README discipline #9): if a future codex renamed the + /// model wrapper back to `models` or emitted bare-string reasoning efforts, the + /// parser must still degrade gracefully (data-first, legacy-fallback) — so a rename + /// never silently empties the model list again. + #[tokio::test] + async fn fill_discovery_accepts_legacy_models_key_and_string_efforts() { + let discovered = Arc::new(std::sync::Mutex::new(Discovered::default())); + // Legacy shape: result.models[] + bare-string supportedReasoningEfforts. + let model_result: Value = serde_json::from_str( + r#"{"models":[{"id":"legacy-1","displayName":"Legacy","supportedReasoningEfforts":["low","high"]}]}"#, + ) + .unwrap(); + fill_discovery(DiscoveryKind::Models, &model_result, &discovered); + let d = discovered.lock().unwrap(); + assert_eq!(d.models.len(), 1, "legacy result.models[] still parses"); + assert_eq!( + d.models[0].reasoning_efforts, + vec!["low".to_string(), "high".to_string()], + "bare-string efforts still accepted" + ); + } + + /// UT-1: `permissionProfile/list` response (0.139.0 live shape) surfaces every profile + /// VERBATIM as a colon-prefixed mode id — built-in tiers AND a user custom profile, + /// preserving discovery order. Legacy-ACP parity: codex defines the value set (like an + /// ACP agent's `availableModes[]`), AionCore does not translate or whitelist. + #[tokio::test] + async fn fill_discovery_surfaces_permission_profiles_verbatim() { + let discovered = Arc::new(std::sync::Mutex::new(Discovered::default())); + // 0.139.0 built-in tiers + a user `[permissions.team-review]` custom profile with a + // display name/description (which we must carry, not drop). + let perm_result: Value = serde_json::from_str( + r#"{"data":[{"id":":read-only","description":null},{"id":":workspace","description":null},{"id":":danger-full-access","description":null},{"id":":team-review","name":"Team Review","description":"Custom profile"}],"nextCursor":null}"#, + ) + .unwrap(); + fill_discovery(DiscoveryKind::Permissions, &perm_result, &discovered); + let d = discovered.lock().unwrap(); + // The catalog `id` is the FRONTEND-facing value: the three built-in tiers are mapped + // to the legacy bare tokens the old ACP path advertised (`:workspace` → `auto`) so + // the picker's i18n keys off the same value; a custom profile has no legacy bare + // form and keeps its colon id verbatim. SetMode's `normalize_to_profile_id` is the + // inverse on the return trip. + assert_eq!( + d.modes.iter().map(|m| m.id.as_str()).collect::>(), + vec!["read-only", "auto", "full-access", ":team-review"], + "built-in tiers map to legacy bare tokens; custom keeps its colon id, in discovery order" + ); + // The custom profile's own name/description are carried through (codex sent them). + let custom = d + .modes + .iter() + .find(|m| m.id == ":team-review") + .expect("custom profile present"); + assert_eq!(custom.name, "Team Review", "custom profile display name carried"); + assert_eq!(custom.description.as_deref(), Some("Custom profile")); + // A name-less built-in profile gets the friendly display copied from the legacy ACP + // bridge (NOT the bare id) — this is the display parity with the old path. The + // display table is keyed on the colon wire id, so the lookup still resolves after the + // catalog id was mapped to its bare token. + let workspace = d.modes.iter().find(|m| m.id == "auto").unwrap(); + assert_eq!( + workspace.name, "Default", + "built-in workspace tier gets the legacy friendly name" + ); + assert_eq!( + workspace.description.as_deref(), + Some( + "Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. (Identical to Agent mode)" + ), + "built-in workspace tier gets the legacy friendly description" + ); + let read_only = d.modes.iter().find(|m| m.id == "read-only").unwrap(); + assert_eq!(read_only.name, "Read Only"); + let full = d.modes.iter().find(|m| m.id == "full-access").unwrap(); + assert_eq!(full.name, "Full Access"); + } + + /// UT-1b: `normalize_to_profile_id` — the ONE translation AionCore still owns — + /// rewrites a legacy persisted BARE value onto its colon-prefixed profile id, passes a + /// discovered/custom colon id through VERBATIM, and never yields a value codex rejects. + /// Mirrors legacy ACP `normalize_requested_mode` (alias → native id). + #[test] + fn codex_perm_normalize_to_profile_id_maps_legacy_and_passes_colon_ids() { + // Legacy bare values rewrite onto the danger-full-access colon id (parity with + // legacy `codex_sandbox`: full-access/yolo/yoloNoSandbox → danger-full-access). + // `agent-full-access` is the #608 canonical id; the rest are pre-021 legacy aliases. + for legacy in ["agent-full-access", "full-access", "yolo", "yoloNoSandbox"] { + assert_eq!(codex_perm::normalize_to_profile_id(legacy), ":danger-full-access"); + } + // read-only bare → its colon id. + assert_eq!(codex_perm::normalize_to_profile_id("read-only"), ":read-only"); + // Unknown / blank / default-ish bare → the safe workspace-write tier. + for legacy in ["default", "auto", "autoEdit", "", " ", "anything-unknown"] { + assert_eq!( + codex_perm::normalize_to_profile_id(legacy), + ":workspace", + "unknown/blank persisted mode falls to the safe workspace-write tier" + ); + } + // A colon-prefixed id (discovered built-in OR user custom profile) passes through + // verbatim — codex, not AionCore, owns the value set (legacy-ACP parity). + for id in [ + ":workspace", + ":danger-full-access", + ":read-only", + ":my-custom", + ":team-review", + ] { + assert_eq!(codex_perm::normalize_to_profile_id(id), id); + } + // Whitespace around a colon id is trimmed, not treated as a bare value. + assert_eq!(codex_perm::normalize_to_profile_id(" :read-only "), ":read-only"); + // A degenerate bare colon is nonsense → safe default, not a passthrough of ":". + assert_eq!(codex_perm::normalize_to_profile_id(":"), ":workspace"); + } + + /// UT-1c: `mode_to_catalog_value` — the `capabilities.current_mode` seed translation — + /// lands EVERY accepted mode vocabulary on a catalog value the picker can highlight. + /// Regression pin for the empty-permission-label bug: resuming an old codex + /// conversation feeds the #608 canonical id `agent-full-access` (produced by + /// `normalize_requested_mode` from the persisted mode) into the seed; the outbound + /// leg alone passed it through verbatim, the catalog only carries + /// [read-only, auto, full-access], and the frontend rendered "权限 ·" with an empty + /// label for a current value that matches no option. + #[test] + fn codex_perm_mode_to_catalog_value_lands_on_catalog_vocabulary() { + // The bug's exact input: canonical full-access id → the catalog's bare token. + assert_eq!(codex_perm::mode_to_catalog_value("agent-full-access"), "full-access"); + // Legacy full-access aliases collapse onto the same catalog value. + for legacy in ["full-access", "yolo", "yoloNoSandbox"] { + assert_eq!(codex_perm::mode_to_catalog_value(legacy), "full-access"); + } + // The other two built-in tiers, in both bare and colon form. + assert_eq!(codex_perm::mode_to_catalog_value("read-only"), "read-only"); + assert_eq!(codex_perm::mode_to_catalog_value(":read-only"), "read-only"); + assert_eq!(codex_perm::mode_to_catalog_value("auto"), "auto"); + assert_eq!(codex_perm::mode_to_catalog_value("default"), "auto"); + assert_eq!(codex_perm::mode_to_catalog_value(":workspace"), "auto"); + // Older persisted colon id for full access (pre-existing seed behavior kept). + assert_eq!(codex_perm::mode_to_catalog_value(":danger-full-access"), "full-access"); + // A custom profile id round-trips colon-and-all — it IS its own catalog value. + assert_eq!(codex_perm::mode_to_catalog_value(":team-review"), ":team-review"); + // Unknown bare tokens land on the workspace tier's value, mirroring the SetMode + // apply path's bucketing so display and applied tier cannot drift. + assert_eq!(codex_perm::mode_to_catalog_value("anything-unknown"), "auto"); + } + + /// feature 012 UT-2: an empty `permissionProfile/list` (older codex or drift) leaves + /// `modes` empty and takes the present-but-empty warn path without panicking. + #[tokio::test] + async fn fill_discovery_permissions_empty_does_not_panic() { + let discovered = Arc::new(std::sync::Mutex::new(Discovered::default())); + let perm_result: Value = serde_json::from_str(r#"{"data":[],"nextCursor":null}"#).unwrap(); + fill_discovery(DiscoveryKind::Permissions, &perm_result, &discovered); + assert!(discovered.lock().unwrap().modes.is_empty(), "empty data[] → no modes"); + } + + /// UT-3: a `permissionProfile/list` RESPONSE broadcasts a `CatalogUpdated` whose + /// `modes` carry the verbatim colon profile ids, and the models field is preserved + /// (orthogonal, not clobbered). + #[tokio::test] + async fn permission_profile_response_broadcasts_catalog_updated() { + use futures_util::StreamExt as _; + let model_resp = r#"{"jsonrpc":"2.0","id":50,"result":{"data":[{"id":"openai.gpt-5.5","displayName":"GPT-5.5"}],"nextCursor":null}}"#; + let perm_resp = r#"{"jsonrpc":"2.0","id":52,"result":{"data":[{"id":":read-only","description":null},{"id":":workspace","description":null},{"id":":danger-full-access","description":null}],"nextCursor":null}}"#; + let bytes = format!("{model_resp}\n{perm_resp}\n").into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); + let backend = CodexSessionBackend::build_with_io("codex-perm", Box::new(fake)).await; + { + let mut pd = backend.pending_discovery.lock().await; + pd.insert(50, DiscoveryKind::Models); + pd.insert(52, DiscoveryKind::Permissions); + } + let mut events = backend.events(); + let mut saw_modes = false; + for _ in 0..80 { + if let Ok(Some(env)) = tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await + && let SessionEvent::CatalogUpdated { models, modes, .. } = env.event + && !modes.is_empty() + { + assert_eq!( + modes.iter().map(|m| m.id.as_str()).collect::>(), + vec!["read-only", "auto", "full-access"], + "three built-in tiers surface as legacy bare tokens on the event" + ); + // models already arrived → the snapshot must preserve it (orthogonal). + assert_eq!(models.len(), 1, "permission modes do not clobber the model list"); + saw_modes = true; + break; + } + } + assert!( + saw_modes, + "a CatalogUpdated snapshot carrying the permission-tier modes must be broadcast" + ); + } + + // ===== codex-model-gating: post-handshake validated model reconcile ===== + + /// Build a backend whose reader has already learned a two-model catalog + /// (`openai.gpt-5.5`, `openai.gpt-5.4`) AND bound a thread — the state + /// `reconcile_codex_model` runs against. Returns (Arc, captured + /// stdin) so a test can drive the reconcile and inspect the frames it writes. + async fn backend_with_catalog_and_binding() -> (Arc, Arc>>) { + let started = r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-rec"}}}"#; + let model_resp = r#"{"jsonrpc":"2.0","id":50,"result":{"data":[{"id":"openai.gpt-5.5","displayName":"GPT-5.5","isDefault":true},{"id":"openai.gpt-5.4","displayName":"gpt-5.4"}],"nextCursor":null}}"#; + let bytes = format!("{started}\n{model_resp}\n").into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-rec", Box::new(fake)).await; + backend.pending_discovery.lock().await.insert(50, DiscoveryKind::Models); + // Drive the reader so it binds the thread + fills the catalog. + let _events = backend.events(); + for _ in 0..40 { + let filled = !backend + .discovered + .lock() + .unwrap_or_else(|e| e.into_inner()) + .models + .is_empty(); + let bound = backend.thread_binding.lock().await.is_some(); + if filled && bound { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + (Arc::new(backend), captured) + } + + /// A requested model that IS in the discovered catalog is applied via a real + /// `thread/settings/update{model}` (the validated apply) — the same wire a manual + /// SetModel uses. This is the codex analogue of ACP's reconcile issuing `set_model` + /// only for a desire that survived `clear_invalid_desired_model`. + #[tokio::test] + async fn codex_model_reconcile_applies_valid_model() { + let (backend, captured) = backend_with_catalog_and_binding().await; + reconcile_codex_model(&backend, "openai.gpt-5.4".into()).await; + let written = captured_str(&captured).await; + assert!( + written.contains(r#""method":"thread/settings/update""#), + "a catalog-valid model is applied via thread/settings/update, got: {written}" + ); + assert!( + written.contains(r#""model":"openai.gpt-5.4""#), + "carries the requested (valid) model, got: {written}" + ); + } + + /// A requested model that is NOT in the catalog (a stale frontend picker default the + /// local codex lacks — the exact "新会话首个回复报上游错误" repro) is DROPPED: no + /// `thread/settings/update` is written (the thread stays on codex's launch default), + /// and the optimistic open-time `current_model` seed is cleared so a later SetMode + /// can't build a collaborationMode around a model codex rejected. This is the port of + /// ACP's `clear_invalid_desired_model`. + #[tokio::test] + async fn codex_model_reconcile_drops_invalid_model_and_clears_seed() { + let (backend, captured) = backend_with_catalog_and_binding().await; + // Optimistic open-time seed (open_session sets this from config.model). + *backend.current_model.lock().await = Some("gpt-5.5-that-local-codex-lacks".into()); + // Run the reconcile inline (awaited directly — no detached task). + reconcile_codex_model(&backend, "gpt-5.5-that-local-codex-lacks".into()).await; + assert!( + backend.current_model.lock().await.is_none(), + "an invalid requested model must clear the optimistic current_model seed" + ); + let written = captured_str_allow_empty(&captured).await; + assert!( + !written.contains(r#""method":"thread/settings/update""#), + "an invalid model must NOT be applied to codex (no thread/settings/update), got: {written}" + ); + } + + /// Drain captured stdin WITHOUT requiring non-empty output (the invalid-model + /// reconcile writes NOTHING, so `captured_str`'s "poll until non-empty" would hang + /// the full 40 iterations then still assert). Bounded settle, returns whatever is there. + async fn captured_str_allow_empty(captured: &Arc>>) -> String { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + String::from_utf8_lossy(&captured.lock().await.clone()).to_string() + } + + // ===== codex-mode-gating: post-handshake validated mode reconcile ===== + + /// Build a backend whose reader has already learned the discovered permission-tier + /// catalog (a `permissionProfile/list` response; the wire ids are the colon ids + /// `:read-only`/`:workspace`/`:danger-full-access`, surfaced to the frontend as the + /// legacy bare tokens `read-only`/`auto`/`full-access`) AND bound a thread — the state + /// `reconcile_codex_mode` runs against. Returns (Arc, captured stdin). The + /// permissions channel needs no current_model, so the caller seeds nothing. + async fn backend_with_mode_catalog_and_binding() -> (Arc, Arc>>) { + let started = r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-mode"}}}"#; + let perm_resp = r#"{"jsonrpc":"2.0","id":60,"result":{"data":[{"id":":read-only","description":null},{"id":":workspace","description":null},{"id":":danger-full-access","description":null}],"nextCursor":null}}"#; + let bytes = format!("{started}\n{perm_resp}\n").into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-mode-rec", Box::new(fake)).await; + backend + .pending_discovery + .lock() + .await + .insert(60, DiscoveryKind::Permissions); + let _events = backend.events(); + for _ in 0..40 { + let filled = !backend + .discovered + .lock() + .unwrap_or_else(|e| e.into_inner()) + .modes + .is_empty(); + let bound = backend.thread_binding.lock().await.is_some(); + if filled && bound { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + (Arc::new(backend), captured) + } + + /// A requested tier that IS in the discovered catalog is applied via a real + /// `thread/settings/update{permissions}` (the validated apply) — the codex analogue of + /// ACP's reconcile issuing `set_mode` only for a desire that survived + /// `clear_invalid_desired_mode`. codex has NO thread/start permissions param, so this + /// is ALSO the first time the persisted tier reaches codex at all. + #[tokio::test] + async fn codex_mode_reconcile_applies_valid_mode() { + let (backend, captured) = backend_with_mode_catalog_and_binding().await; + reconcile_codex_mode(&backend, "full-access".into()).await; + let written = captured_str(&captured).await; + assert!( + written.contains(r#""method":"thread/settings/update""#), + "a catalog-valid tier is applied via thread/settings/update, got: {written}" + ); + assert!( + written.contains(r#""permissions":":danger-full-access""#), + "carries the requested tier as its colon profile id, got: {written}" + ); + } + + /// A legacy persisted BARE value (`yolo`) normalizes onto the `:danger-full-access` + /// colon id that IS in the discovered catalog and applies straight through — zero + /// fallback for an upgrading user. + #[tokio::test] + async fn codex_mode_reconcile_normalizes_legacy_persisted_value() { + let (backend, captured) = backend_with_mode_catalog_and_binding().await; + reconcile_codex_mode(&backend, "yolo".into()).await; + let written = captured_str(&captured).await; + assert!( + written.contains(r#""permissions":":danger-full-access""#), + "legacy `yolo` normalizes to full-access → :danger-full-access, got: {written}" + ); + } + + /// A requested value that normalizes to a tier NOT in the catalog is DROPPED: no + /// `thread/settings/update` is written (the thread stays on codex's default). This can + /// only happen when discovery returned a partial catalog (e.g. only `:workspace`), so + /// we seed a single-tier catalog and request `read-only`. + #[tokio::test] + async fn codex_mode_reconcile_drops_tier_absent_from_partial_catalog() { + // Build a backend whose catalog has ONLY the default tier. + let started = r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-mode"}}}"#; + let perm_resp = + r#"{"jsonrpc":"2.0","id":61,"result":{"data":[{"id":":workspace","description":null}],"nextCursor":null}}"#; + let bytes = format!("{started}\n{perm_resp}\n").into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); + let captured = fake.captured_stdin(); + let backend = CodexSessionBackend::build_with_io("codex-mode-partial", Box::new(fake)).await; + backend + .pending_discovery + .lock() + .await + .insert(61, DiscoveryKind::Permissions); + let _events = backend.events(); + for _ in 0..40 { + let filled = !backend + .discovered + .lock() + .unwrap_or_else(|e| e.into_inner()) + .modes + .is_empty(); + let bound = backend.thread_binding.lock().await.is_some(); + if filled && bound { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + reconcile_codex_mode(&backend, "read-only".into()).await; + let written = captured_str_allow_empty(&captured).await; + assert!( + !written.contains(r#""method":"thread/settings/update""#), + "a tier absent from the (partial) catalog must NOT be applied, got: {written}" + ); + } + + // ===== O2: thread/turns/list response → CheckpointList event (up-leg) ===== + + /// Gap-2: `dispatch(ListCheckpoints)` writes `thread/turns/list` AND the reader + /// maps the response's `data: Vec` to `SessionEvent::CheckpointList`. The + /// down-leg alone (writing the RPC) was wired; without the up-leg the response + /// landed with no consumer. This drives the response through the reader and + /// asserts the event surfaces with the turns mapped to CheckpointEntry. + #[tokio::test] + async fn list_checkpoints_response_maps_to_checkpoint_list_event() { + use futures_util::StreamExt as _; + + // A thread/turns/list response (codex ThreadTurnsListResponse{data:[Turn]}). + let turns_resp = r#"{"jsonrpc":"2.0","id":77,"result":{"data":[{"id":"turn-a","status":"completed"},{"id":"turn-b","status":"running"}],"next_cursor":null}}"#; + let bytes = format!("{turns_resp}\n").into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); + let backend = CodexSessionBackend::build_with_io("codex-ck", Box::new(fake)).await; + // Register the pending id the reader will claim (dispatch does this; here we + // mimic it so the response — keyed 77 — is recognized as a Checkpoints query). + backend + .pending_discovery + .lock() + .await + .insert(77, DiscoveryKind::Checkpoints); + // Subscribe BEFORE the reader consumes the response (broadcast drops + // pre-subscribe messages), then collect until CheckpointList shows up. + let mut events = backend.events(); + let mut found = None; + for _ in 0..40 { + match tokio::time::timeout(std::time::Duration::from_millis(25), events.next()).await { + Ok(Some(env)) => { + if let SessionEvent::CheckpointList { entries } = env.event { + found = Some(entries); + break; + } + } + _ => continue, + } + } + let entries = found.expect("reader emitted a CheckpointList event for the turns/list response"); + assert_eq!(entries.len(), 2, "both turns mapped to checkpoint entries"); + assert_eq!(entries[0].id, "turn-a", "Turn.id → CheckpointEntry.id"); + assert_eq!( + entries[0].label.as_deref(), + Some("completed"), + "Turn.status → CheckpointEntry.label" + ); + assert_eq!(entries[1].id, "turn-b"); + assert!( + entries.iter().all(|e| e.turn_gen.is_none()), + "codex turns carry no adapter turn_gen" + ); + } + + // ===== R5: AnswerAuth vs serverRequest/resolved race on pending_auth_id ===== + + /// 🔴 R5 (race characterization) — the reader's `serverRequest/resolved` handler + /// clears `pending_auth_id` (codex_conn.rs:619) while `dispatch(AnswerAuth)` + /// `.take()`s it (codex_conn.rs:1427). If the resolved-notif wins the race, the + /// user's freshly-supplied credentials are DROPPED: AnswerAuth finds None and + /// returns Err("no pending auth refresh to answer") — the token RESPONSE is never + /// written, so codex's blocking refresh request can hang, and the user sees a + /// spurious failure for credentials they correctly supplied. + /// + /// This pins the CURRENT behavior of the lose-the-race ordering (resolved arrives + /// first). It is the highest-blast-radius auth race: if the fix later makes + /// AnswerAuth idempotent-after-resolved, this test's assertion changes and flags + /// the intended behavior shift. + #[tokio::test] + async fn r5_answer_auth_after_resolved_clears_pending_drops_credentials() { + // Feed BOTH the refresh request (id=99, sets pending_auth_id) AND the + // serverRequest/resolved for the same id (clears pending_auth_id) — the + // reader processes both before we dispatch, modeling "resolved won the race". + let lines = [ + r#"{"jsonrpc":"2.0","id":99,"method":"account/chatgptAuthTokens/refresh","params":{"reason":"expired"}}"#, + r#"{"jsonrpc":"2.0","method":"serverRequest/resolved","params":{"request_id":99}}"#, + ]; + let bytes = format!("{}\n", lines.join("\n")).into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); + let backend = CodexSessionBackend::build_with_io("codex-r5", Box::new(fake)).await; + // Drain events until we see PermissionResolved{Auth} — proves the reader + // processed the resolved notif and cleared pending_auth_id. + { + let mut events = backend.events(); + let saw_resolved = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if matches!( + env.event, + SessionEvent::PermissionResolved { + kind: PermissionKind::Auth, + .. + } + ) { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!( + saw_resolved, + "the resolved notif must surface as PermissionResolved(Auth) (pending_auth_id cleared)" + ); + } + // Now the user answers — but pending_auth_id was already cleared by resolved. + let res = backend + .dispatch(Command::AnswerAuth { + method_id: "chatgptAuthTokens".into(), + credentials: json!({ "accessToken": "jwt-late", "chatgptAccountId": "acct-1" }), + }) + .await; + // CURRENT behavior: credentials are dropped with a Transport error. + assert!( + matches!(&res, Err(BackendError::Transport(m)) if m.contains("no pending auth refresh")), + "R5: AnswerAuth losing the race to serverRequest/resolved drops the user's \ + credentials with a 'no pending auth refresh' error (got {res:?}). If this \ + assertion changes, the auth-race handling was intentionally reworked." + ); + } + + /// 🔴 R10 (pending-sends leak on error response) — a `turn/start` ERROR response + /// (codex_conn.rs:658-660: PromptAccepted emitted ONLY when `result` present) + /// removes the rpc_id→client_msg_id correlation WITHOUT emitting PromptAccepted. + /// The conversation's pending queue drains on PromptAccepted{client_msg_id} + /// (drain_pending_on), so that message NEVER drains → a ghost "in flight" + /// message stuck forever in the composer's pending list. This pins the current + /// behavior: an error response yields NO PromptAccepted (the leak source). + #[tokio::test] + async fn r10_turn_start_error_response_emits_no_prompt_accepted_leaks_pending() { + // A turn/start ERROR response (id=1, the first next_rpc_id) — codex rejected + // the turn. The reader removes the pending_sends entry but emits nothing. + let err_resp = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"turn rejected"}}"#; + let prefix = format!( + "{}\n", + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-r10"}}}"# + ) + .into_bytes(); + let fake = FakeAgentIo::new(prefix, None).with_gated_tail(format!("{err_resp}\n").into_bytes()); + let release = fake.stdout_releaser(); + let backend = CodexSessionBackend::build_with_io("codex-r10", Box::new(fake)).await; + let mut events = backend.events(); + // Send with a client_msg_id → registers pending_sends[1] = "m-1". + let receipt = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("hi".into())], + metadata: super::super::types::CommandMeta { + client_msg_id: Some("m-1".into()), + ..Default::default() + }, + }) + .await + .expect("send accepted (codex turn/start written)"); + assert!(receipt.accepted); + // Release the error response into the reader. + release(); + // Collect events briefly; assert NO PromptAccepted for m-1 surfaces. + let mut saw_prompt_accepted = false; + for _ in 0..8 { + match tokio::time::timeout(std::time::Duration::from_millis(100), events.next()).await { + Ok(Some(env)) => { + if matches!(env.event, SessionEvent::PromptAccepted { .. }) { + saw_prompt_accepted = true; + break; + } + } + _ => break, + } + } + assert!( + !saw_prompt_accepted, + "R10: a turn/start ERROR response must NOT emit PromptAccepted — but then the \ + conversation's pending m-1 never drains (ghost in-flight message). This pins \ + the leak: the error path needs a 'send failed → drop pending' signal the \ + conversation can act on. If a future fix emits a failure/drain signal here, \ + this assertion + its rationale change." + ); + } + + /// 🔴 R8 (bound_thread timeout during a never-arriving handshake) — every + /// turn/* dispatch calls `bound_thread()` which polls up to ~2s for the async + /// `thread/started` (codex_conn.rs:480-488). If the handshake never arrives + /// (process crashed at startup / slow app-server), the dispatch blocks the full + /// window then errors `BackendError::Transport`. Since the dispatch errors, no + /// PromptAccepted fires → a conversation that already pushed a pending message + /// leaks it (same ghost-pending family as R10). Pins the timeout-then-error. + #[tokio::test] + async fn r8_send_during_never_arriving_handshake_times_out_and_errors() { + // No thread/started ever — bound_thread exhausts its budget. Use a short env + // budget so the test does not wait the full 30s (the real cold-start window). + // SAFETY: restored after; this assertion is about the ERROR CLASSIFICATION. + let saved = std::env::var("AIONUI_HANDSHAKE_TIMEOUT_SECS").ok(); + unsafe { std::env::set_var("AIONUI_HANDSHAKE_TIMEOUT_SECS", "1") }; + let fake = FakeAgentIo::never_exits(Vec::new()); + let backend = CodexSessionBackend::build_with_io("codex-r8", Box::new(fake)).await; + let res = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("hi".into())], + metadata: super::super::types::CommandMeta { + client_msg_id: Some("m-1".into()), + ..Default::default() + }, + }) + .await; + match saved { + Some(v) => unsafe { std::env::set_var("AIONUI_HANDSHAKE_TIMEOUT_SECS", v) }, + None => unsafe { std::env::remove_var("AIONUI_HANDSHAKE_TIMEOUT_SECS") }, + } + // codex-500 fix: the error must be the RETRYABLE HandshakeTimeout (agent still + // starting), NOT a bare Transport that mapped to an opaque 500. (Was Transport + // before — this test documented the bug as if it were correct.) + assert!( + matches!(&res, Err(BackendError::HandshakeTimeout(m)) if m.contains("threadId not bound")), + "R8: a Send during a never-arriving handshake must error HandshakeTimeout (retryable), got {res:?}" + ); + } + + /// 🔴 R4 (active_turn_id clear-wins-race vs Steer) — the reader clears + /// `active_turn_id=None` on turn/completed (codex_conn.rs:597-598) while + /// dispatch(Steer) reads it (codex_conn.rs:1363). When a turn finishes at the + /// instant the user steers, the CLEAR-WINS ordering makes Steer read `None` → + /// return Err("no active turn to steer"). Consumer symptom: a steer issued at + /// end-of-turn is reported as a failure even though the user's intent (inject + /// text into the turn) simply arrived a beat too late. This pins the clear-wins + /// ordering (deterministic: the turn is fully completed before we steer). + #[tokio::test] + async fn r4_steer_after_turn_completed_clears_active_id_is_rejected() { + // Full turn lifecycle: thread/started (bind) + turn/started (set active) + + // turn/completed (CLEAR active_turn_id). All processed before we steer. + let lines = [ + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-r4"}}}"#, + r#"{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"th-r4","turn":{"id":"turn-r4"}}}"#, + r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"th-r4","turn":{"id":"turn-r4","status":"completed"}}}"#, + ]; + let bytes = format!("{}\n", lines.join("\n")).into_bytes(); + let fake = FakeAgentIo::never_exits(bytes); + let backend = CodexSessionBackend::build_with_io("codex-r4", Box::new(fake)).await; + // Drain events until TurnResult — proves the reader processed turn/completed + // (and therefore cleared active_turn_id). + { + let mut events = backend.events(); + let saw_terminal = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::TurnResult { .. }) { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!( + saw_terminal, + "turn/completed must produce a TurnResult (active_turn_id cleared)" + ); + } + // Now steer — the active turn is gone (cleared by the reader). + let res = backend + .dispatch(Command::Steer { + content: vec![ContentBlock::Text("wait, also do X".into())], + }) + .await; + assert!( + matches!(&res, Err(BackendError::Transport(m)) if m.contains("no active turn to steer")), + "R4: a Steer after the turn completed (active_turn_id cleared) is rejected with \ + 'no active turn to steer' (got {res:?}) — the user's end-of-turn steer text \ + vanishes as a failure. If a fix queues a just-missed steer as a fresh turn, \ + this assertion changes." + ); + } + + /// 🟡 R1 (concurrent-send turn_gen safety invariant) — two `dispatch(Send)` + /// calls racing on the SAME backend each do `turn_gen.fetch_add` (codex_conn.rs: + /// 1307). The epoch ORDER vs wire order can reorder (the full reorder is a + /// loom-class property), but the LOAD-BEARING safety invariant is cheaper and + /// must hold under any interleaving: the two receipts get DISTINCT, monotonic + /// turn_gens (never the same epoch — a collision would conflate two turns' FSM + /// epochs and break the cross-turn stale-result guard). This pins that invariant + /// across a real concurrent race (AtomicU64 fetch_add guarantees it; the test + /// would catch a regression that made turn_gen assignment non-atomic). + #[tokio::test] + async fn r1_concurrent_sends_get_distinct_monotonic_turn_gen() { + use std::sync::Arc; + // Bind the thread (NO active turn) so both sends reach the real fetch_add + // arm. NOTE (009 R1c): we deliberately do NOT pre-bind an active turn here. + // With an active turn the correct behavior is now NoTurn (a flight-period + // Send merges, opening no second turn_gen — see + // dispatch_send_during_active_turn_is_noturn_…). This test isolates the + // OTHER invariant — that turn_gen assignment is atomic under a real + // concurrent race when both sends legitimately open turns (no active turn + // to merge into) — so the two arms get DISTINCT monotonic epochs. + let lines = [r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-r1"}}}"#]; + let fake = FakeAgentIo::never_exits(format!("{}\n", lines.join("\n")).into_bytes()); + let backend = Arc::new(CodexSessionBackend::build_with_io("codex-r1", Box::new(fake)).await); + // Let the reader bind the thread before dispatching. + tokio::time::sleep(std::time::Duration::from_millis(80)).await; + let send = |b: Arc, n: usize| async move { + b.dispatch(Command::Send { + content: vec![ContentBlock::Text(format!("msg-{n}"))], + metadata: super::super::types::CommandMeta { + client_msg_id: Some(format!("m-{n}")), + ..Default::default() + }, + }) + .await + }; + let (r1, r2) = tokio::join!(send(backend.clone(), 1), send(backend.clone(), 2)); + let g1 = r1.expect("send 1 accepted").turn_gen; + let g2 = r2.expect("send 2 accepted").turn_gen; + assert_ne!( + g1, g2, + "R1: two concurrent sends MUST get distinct turn_gens (no epoch collision); got {g1} and {g2}" + ); + let (lo, hi) = (g1.min(g2), g1.max(g2)); + assert_eq!( + (lo, hi), + (1, 2), + "R1: concurrent sends get monotonic epochs 1 and 2 (no skip/dup), got {g1} and {g2}" + ); + } + + // ===== CAPSTONE: a REAL CodexSessionBackend folded through the Orchestrator ===== + + /// The 007 thesis end-to-end: a real `CodexSessionBackend` parsing actual codex + /// JSON-RPC, driven through `Orchestrator::run()`, produces the unlock via + /// `StateSnapshot.can_send` — with the SAME reducer/FSM claude uses (R11). This + /// proves the seam is transport-agnostic: codex wire → SessionEvent → step() → + /// snapshot, no codex-specific reducer path. The fake replays a full turn: + /// thread/started → turn/started → agentMessage deltas → turn/completed. + #[tokio::test] + async fn codex_backend_folds_through_orchestrator_to_unlock() { + use super::super::Orchestrator; + use crate::state::SessionState; + use futures_util::StreamExt as _; + + // Two-phase fixture (mirrors production ordering): the HANDSHAKE prefix + // (`thread/started`) flows immediately so `send`'s bound_thread resolves; + // the TURN tail is GATED until run() has subscribed to events() (broadcast + // drops messages sent before subscribe). NOTE: no turn/started fold here — + // the orchestrator lowers TurnStarted itself on send() (I9); the backend's + // turn/started is optimistic (maps to vec![]). turn/completed folds + // Running→Idle (the unlock). + let prefix = format!( + "{}\n", + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-cap"}}}"# + ) + .into_bytes(); + let tail_lines = [ + r#"{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"th-cap","turn":{"id":"turn-cap"}}}"#, + r#"{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"itemId":"m1","delta":"hello "}}"#, + r#"{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"itemId":"m1","delta":"world"}}"#, + r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"th-cap","turn":{"id":"turn-cap","status":"completed"}}}"#, + ]; + let tail = format!("{}\n", tail_lines.join("\n")).into_bytes(); + let fake = FakeAgentIo::new( + prefix, + Some(crate::event::ExitStatusLite { + code: Some(0), + signal: None, + }), + ) + .with_gated_tail(tail); + fake.release_exit(); + // Grab a release handle BEFORE the fake is boxed into the backend. + let release = fake.stdout_releaser(); + let backend = CodexSessionBackend::build_with_io("sess-cap", Box::new(fake)).await; + + let orch = std::sync::Arc::new(Orchestrator::new(256)); + // Subscribe BEFORE the turn so we capture every snapshot. + let mut states = orch.subscribe_state("sess-cap"); + + // send() lowers TurnStarted (Idle→Running, can_send=false). The dispatch + // writes turn/start, but the FakeAgentIo doesn't service requests — the + // turn is driven by the SCRIPTED notifications above. bound_thread resolves + // from the thread/started the reader parses. + let receipt = orch + .send( + &backend, + "sess-cap", + vec![ContentBlock::Text("hi".into())], + super::super::types::CommandMeta::default(), + ) + .await + .expect("send accepted"); + assert_eq!(receipt.admission, Admission::Started); + + // Run the fold loop until the backend stream ends (EOF after turn/completed). + // `send` only borrowed `backend`; we now move it into the run task. + let run = { + let orch = orch.clone(); + tokio::spawn(async move { orch.run(&backend).await }) + }; + // run() now subscribes to events() inside the task; give it a tick to land, + // THEN open the stdout gate so the scripted turn drives into a live subscriber. + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + release(); + + // Collect snapshots until we see the unlock (can_send=true while Idle). + let unlocked = tokio::time::timeout(std::time::Duration::from_secs(5), async { + let mut saw_locked_running = false; + while let Some(snap) = states.next().await { + if snap.session_id != "sess-cap" { + continue; + } + if matches!(snap.state, SessionState::Running { .. }) && !snap.can_send { + saw_locked_running = true; + } + if matches!(snap.state, SessionState::Idle) && snap.can_send && saw_locked_running { + return true; // locked during the turn, unlocked at the end + } + } + false + }) + .await + .expect("must not hang"); + + assert!( + unlocked, + "a real codex turn folded through the orchestrator must lock during the turn and unlock (can_send=true) at turn/completed" + ); + // The run loop ends when the backend stream EOFs. + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), run).await; + } + + /// F-4 default: build_with_io → idle_ttl=None → never suspends (no timer, slot + /// Active for life). Protects the parse/dispatch contract from any F-4 cost. + #[tokio::test] + async fn f4_off_by_default_no_suspension() { + let backend = + CodexSessionBackend::build_with_io("codex-1", Box::new(FakeAgentIo::never_exits(Vec::new()))).await; + assert!(backend.idle_timer.is_none(), "no idle timer when idle_ttl is None"); + assert!(backend.suspend.is_active().await, "slot Active"); + tokio::time::sleep(std::time::Duration::from_millis(60)).await; + assert!(backend.suspend.is_active().await, "stays Active (production parity)"); + } + + /// F-4 suspend→wake: a configured idle_ttl suspends the idle app-server; the + /// next dispatch(Send) wakes by re-spawning `codex app-server` through the + /// injected spawner (then replaying the thread/resume handshake against the + /// bound threadId). FakeSpawner records the spawn then Errs, so dispatch + /// surfaces the wake error — the hermetic proof the resume re-spawn ran. + #[tokio::test] + async fn f4_suspend_then_wake_respawns_through_spawner() { + use crate::testing::FakeSpawner; + let spawner = Arc::new(FakeSpawner::new()); + let backend = CodexSessionBackend::build_with_io_suspending( + "codex-resume-1", + Box::new(FakeAgentIo::never_exits(Vec::new())), + spawner.clone(), + 40, + ) + .await; + // The resume anchor that survives the suspend (live path binds it from + // thread/started; seed it here). + backend.seed_thread_binding_for_test("th-anchor-1").await; + assert!(backend.idle_timer.is_some(), "idle timer spawned when ttl is Some"); + + // Force a suspend without waiting on the timer cadence. + assert!( + backend + .suspend + .suspend_if_idle(aionui_common::now_ms() + 10_000, false) + .await + ); + assert!(!backend.suspend.is_active().await, "now Dormant"); + + // Next Send must wake → re-spawn `codex app-server` through the spawner. + let err = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("wake".into())], + metadata: super::super::types::CommandMeta::default(), + }) + .await + .expect_err("FakeSpawner cannot make a real process → wake Errs"); + assert!( + matches!(&err, BackendError::Transport(m) if m.contains("resume-spawn failed")), + "dispatch surfaced the wake re-spawn error, got {err:?}" + ); + assert_eq!(spawner.call_count(), 1, "wake routed through the injected spawner once"); + let spec = spawner.last_command().await.expect("a spawn was recorded"); + assert_eq!(spec.command.to_str(), Some("codex"), "wake re-spawns the codex binary"); + assert!( + spec.args.iter().any(|a| a == "app-server"), + "wake re-spawns `codex app-server`, got {:?}", + spec.args + ); + drop(backend); + } + + /// codex `fileChange` completed item → `ToolResultContent::FilePath` per change. + /// WIRED (was the Gap #6 TRIPWIRE): a real completed-item fixture (0.139.0, + /// missing-wire-probe) confirmed the shape is `changes:[{path, kind:{type}, diff}]` + /// — NOT a flat top-level `path`. map_item now mints one FilePath per change, + /// carrying the unified `diff` as `new_text` so the TurnFinalizer renders a + /// FileDiff card. The `aggregatedOutput` Text still rides alongside. + #[test] + fn codex_filechange_extracts_filepath_from_changes() { + let params = serde_json::json!({ + "item": { + "type": "fileChange", + "id": "call_1", + "status": "completed", + "changes": [{ + "path": "/w/patch_target.py", + "kind": { "type": "update", "move_path": null }, + "diff": "@@ -3,2 +3,3 @@\n \n-# TODO\n+def subtract(a, b):\n+ return a - b\n" + }] + } + }); + let events = map_item(¶ms, true); + let content: Vec<&crate::event::ToolResultContent> = events + .iter() + .filter_map(|e| match e { + SessionEvent::ToolResult { content, .. } => Some(content.iter()), + _ => None, + }) + .flatten() + .collect(); + let filepath = content + .iter() + .find_map(|c| match c { + crate::event::ToolResultContent::FilePath { path, new_text, .. } => { + Some((path.clone(), new_text.clone())) + } + _ => None, + }) + .expect("fileChange change → FilePath"); + assert_eq!(filepath.0, "/w/patch_target.py"); + assert!( + filepath.1.as_deref().is_some_and(|d| d.contains("def subtract")), + "the unified diff rides FilePath.new_text, got {:?}", + filepath.1 + ); + } + + /// codex `imageGeneration` completed → the produced image's PATH reaches a + /// FilePath ToolResultContent. The wire key is `savedPath` (source-verified + /// v2/item.rs:372-380 ImageGeneration{result:String(base64), saved_path → camelCase + /// `savedPath`}), NOT a guessed `path`, and NOT the base64 `result` (which would + /// dump bytes). Was a TRIPWIRE recording the drop ("shape not captured"); the + /// shape WAS in the schema all along — flipped to assert the path is carried. + #[test] + fn codex_imagegeneration_saved_path_reaches_file_path() { + let params = serde_json::json!({ + "item": { + "type": "imageGeneration", + "id": "call_x", + "status": "completed", + "result": "iVBORw0KGgo=", // base64 bytes — must NOT be dumped as Text + "savedPath": "/w/generated.png" + } + }); + let events = map_item(¶ms, true); + let content: Vec<&crate::event::ToolResultContent> = events + .iter() + .filter_map(|e| match e { + SessionEvent::ToolResult { content, .. } => Some(content.iter()), + _ => None, + }) + .flatten() + .collect(); + assert!( + content.iter().any(|c| matches!(c, + crate::event::ToolResultContent::FilePath { path, .. } if path == "/w/generated.png")), + "imageGeneration savedPath must reach a FilePath ToolResultContent, got {content:?}" + ); + assert!( + !content.iter().any(|c| matches!(c, + crate::event::ToolResultContent::Text(t) if t.contains("iVBORw0KGgo"))), + "the base64 `result` bytes must NOT be dumped as Text, got {content:?}" + ); + } + + /// LC-8a: codex `turn/plan/updated` → `SessionEvent::Plan`. step→content, + /// camelCase `inProgress`→InProgress, priority None (codex has none), explanation + /// carried. + /// + /// WIRE-CONFIRMED (live capture protocols/samples/codex-cli/0.139.0/ + /// _all_rollback_plan.jsonl): a real turn/plan/updated is + /// `{threadId, turnId, explanation:null, plan:[{step:"...", status:"pending"}, ...]}` + /// — the `{step, status}` per-entry keys + top-level `explanation` match exactly. + /// The live run only exhibited status "pending" (all steps pending at plan creation); + /// the `inProgress`/`completed` values below are the schema-defined enum tokens + /// (TurnPlanStepStatus, 0.137.0) the normalizer also handles — kept to pin the + /// status mapping across all three states. + #[test] + fn turn_plan_updated_maps_to_plan_event() { + use crate::event::{PlanStatus, SessionEvent}; + let params = serde_json::json!({ + "threadId": "th1", + "turnId": "t1", + "explanation": "stepwise", + "plan": [ + { "step": "read the code", "status": "completed" }, + { "step": "write the fix", "status": "inProgress" }, + { "step": "run tests", "status": "pending" }, + ], + }); + let events = map_notification("turn/plan/updated", ¶ms); + match &events[..] { + [SessionEvent::Plan { entries, explanation }] => { + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].content, "read the code"); + assert_eq!(entries[0].status, PlanStatus::Completed); + assert_eq!( + entries[1].status, + PlanStatus::InProgress, + "camelCase inProgress normalized" + ); + assert_eq!(entries[2].status, PlanStatus::Pending); + assert!(entries[0].priority.is_none(), "codex carries no per-step priority"); + assert_eq!(explanation.as_deref(), Some("stepwise")); + } + other => panic!("expected one Plan event, got {other:?}"), + } + } + + /// Regression-by-rewrite (codex-500): the bound-thread handshake budget must be the + /// legacy 30s (aionui-agent-rest INIT_TIMEOUT_SECS), NOT the magic 2s the rewrite + /// introduced. Pins the value so a future shrink reds here, and that the env override + /// is honored. (Pure — no timing.) + #[test] + fn handshake_budget_is_legacy_30s_default_env_overridable() { + // Default (no env): 30s parity with legacy ACP. We assert >= 15s so a future + // tweak within reason passes, but the old 2s would fail loudly. + // SAFETY: single-threaded assertion on a process-global; we restore after. + let saved = std::env::var("AIONUI_HANDSHAKE_TIMEOUT_SECS").ok(); + unsafe { std::env::remove_var("AIONUI_HANDSHAKE_TIMEOUT_SECS") }; + assert!( + super::super::handshake_budget() >= std::time::Duration::from_secs(15), + "handshake budget must cover a cold start (>=15s), NOT the old magic 2s" + ); + unsafe { std::env::set_var("AIONUI_HANDSHAKE_TIMEOUT_SECS", "45") }; + assert_eq!( + super::super::handshake_budget(), + std::time::Duration::from_secs(45), + "env override honored" + ); + match saved { + Some(v) => unsafe { std::env::set_var("AIONUI_HANDSHAKE_TIMEOUT_SECS", v) }, + None => unsafe { std::env::remove_var("AIONUI_HANDSHAKE_TIMEOUT_SECS") }, + } + } + + /// Regression-by-rewrite (codex-500): when thread/started never arrives, bound_thread + /// must return the RETRYABLE `HandshakeTimeout` (agent still starting), NOT a bare + /// `Transport` (which mapped to an opaque 500). Uses a tiny injected budget so the + /// timeout branch is exercised deterministically (no 30s wait, no global env). + #[tokio::test] + async fn bound_thread_timeout_is_handshake_timeout_not_transport() { + // never_exits + empty stdout → no thread/started → never binds. + let fake = FakeAgentIo::never_exits(Vec::new()); + let backend = CodexSessionBackend::build_with_io("codex-noth", Box::new(fake)).await; + let err = backend + .bound_thread_within(std::time::Duration::from_millis(120)) + .await + .expect_err("no thread/started → must time out"); + assert!( + matches!(err, BackendError::HandshakeTimeout(_)), + "handshake wait timeout must be the RETRYABLE HandshakeTimeout (not Transport→500), got {err:?}" + ); + } + + /// Positive: a thread/started that arrives LATE (past the old 2s would have failed) + /// but within budget still binds — proving the longer budget covers a slow start. + #[tokio::test] + async fn bound_thread_binds_when_thread_started_arrives_within_budget() { + let fake = FakeAgentIo::never_exits(Vec::new()); + let backend = CodexSessionBackend::build_with_io("codex-late", Box::new(fake)).await; + // Simulate a slow handshake: bind the thread after 150ms (the reader would do + // this on a real late thread/started). bound_thread_within(2s) must still succeed. + let binding = backend.thread_binding.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + *binding.lock().await = Some("th-late".to_string()); + }); + let tid = backend + .bound_thread_within(std::time::Duration::from_secs(2)) + .await + .expect("a within-budget late binding must succeed"); + assert_eq!( + tid, "th-late", + "the late-arriving threadId binds (no premature timeout)" + ); + } +} diff --git a/crates/aionui-session/src/backend/conversation_session.rs b/crates/aionui-session/src/backend/conversation_session.rs new file mode 100644 index 000000000..012406190 --- /dev/null +++ b/crates/aionui-session/src/backend/conversation_session.rs @@ -0,0 +1,1547 @@ +//! 007 §3 / C7: the `ConversationSession` — the conversation-side façade over +//! the seam. This is the SKELETON 007 ships (R14); the actual WIRING into +//! `aionui-conversation` (replacing `RuntimeCompletionPublisher` + the hardcoded +//! `turn.completed{canSendMessage}`) is the parallel conversation agent's P2 +//! work. 007 freezes the contract here and proves the skeleton compiles + folds. +//! +//! ## What it is +//! A thin handle a conversation layer holds per logical session. It owns: +//! - an `Arc` (the fold loop / broadcast fan-out), +//! - an `Arc` (the per-session transport handle; also the +//! on-demand source of `Capabilities`), +//! - a local `pending` queue (§9.11 — pending lives on the conversation side, +//! the session never owns it; drained on `PromptAccepted{client_msg_id}`). +//! +//! ## What it is NOT (the frozen MUST-NOTs, §C7) +//! It NEVER calls `step()`, NEVER recomputes the unlock via `can_send_message` +//! (it reads `StateSnapshot.can_send`), NEVER mints `turn_gen`, NEVER blocks on a +//! dispatch return for turn completion, NEVER reaches into the adapter/transport. +//! It MAY ONLY: send Commands (threading `client_msg_id`), consume the demuxed +//! `SessionEnvelope` stream, drain pending on `PromptAccepted`, subscribe to the +//! read-only `StateSnapshot`/unlock streams, read `Capabilities` on demand (G4 — +//! pass-through to the backend so async discovery is reflected), and call +//! `reconnect()` on `Lagged`/transport drop. + +use std::sync::Arc; + +use tokio::sync::Mutex; + +use super::types::{ + Admission, BackendError, CancelTarget, CommandMeta, CommandReceipt, ContentBlock, PermissionDecision, + SessionEnvelope, StateSnapshot, +}; +use super::{Orchestrator, SessionBackend}; +use crate::capability::Capabilities; +use futures_util::stream::BoxStream; + +/// 009 R4 / §2: the lifecycle status of an outstanding message. A message is +/// ALWAYS enqueued `Held` first (the user may type any time, §Decision①); the flush +/// engine dispatches it (`Held`→`Sent`) on a `can_send||can_queue` rising edge; +/// the matching `PromptAccepted` confirms it (`Sent`→`Accepted`). `Canceled` is +/// the terminal for a Held message removed before dispatch (T7) or a session-wide +/// teardown (T7c); `Error` for a dispatch that failed (PC-ERROR-7). +/// +/// ⚠️ The cancel BOUNDARY is "has it been dispatched", NOT "was PromptAccepted +/// seen": only `Held` (never dispatched) may be locally dropped; `Sent`/`Accepted` +/// are in flight at the backend and must go through Cancel{Turn} (T8), never a +/// local delete (that would leave a ghost turn — PC-RACE-5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MsgStatus { + /// Enqueued, not yet dispatched. The ONLY status a local cancel may drop. + Held, + /// Dispatched to the backend (bytes written), awaiting `PromptAccepted`. + Sent, + /// Backend confirmed via `PromptAccepted{client_msg_id}`. + Accepted, + /// Dispatch returned an error (transport dead / FSM Error) — never confirmed. + Error, + /// Removed before dispatch (T7) or cleared by session teardown (T7c). + Canceled, +} + +/// A message the conversation has accepted from the user (§9.11 / Addendum 3). +/// Lives ONLY on the conversation side — the session core never owns pending. +/// Carries a lifecycle `status` (009 R4/§2) and a monotonic `enqueue_ordinal` so +/// the flush engine can pick the head deterministically (HashMap/Vec order is not +/// relied on for FIFO). +#[derive(Debug, Clone, PartialEq)] +pub struct PendingMessage { + /// Correlation key echoed by the adapter on `PromptAccepted`. The façade + /// mints it and threads it through `CommandMeta.client_msg_id`. + pub client_msg_id: String, + /// The user content awaiting backend confirmation. + pub content: Vec, + /// Lifecycle status (009 R4/§2). New entries start `Held`. + pub status: MsgStatus, + /// Monotonic enqueue order — the flush engine dispatches the lowest-ordinal + /// `Held` entry first, so FIFO does not depend on container iteration order. + pub enqueue_ordinal: u64, +} + +/// The conversation-side façade (§3). Holds the orchestrator + backend + the +/// local pending queue. `Capabilities` are read on demand from the backend (G4), +/// not cached, so async discovery (codex model/list, ACP authMethods) is seen. +pub struct ConversationSession { + /// Stable logical session id (§4.1) — the demux key for every stream. + session_id: String, + /// The fold loop / broadcast fan-out. Shared (the run loop is spawned + /// elsewhere; the façade only sends + subscribes). + orchestrator: Arc, + /// The per-session transport handle. `dispatch` is `&self`-concurrent; + /// `capabilities()` is a cheap read-only snapshot (sync, no await). + backend: Arc, + /// Local pending queue (§9.11). Monotonic client_msg_id counter + the queue. + pending: Mutex, + /// Per-INSTANCE prefix for minted `client_msg_id`s (= the wire-frame `uuid` + /// claude stores in its resume message-tree). MUST differ across instances of + /// the same conversation: the counter resets to 0 on every fresh instance, so a + /// purely ordinal id (`m-1`, `m-2`, …) re-collides with the uuids the PRIOR run + /// already persisted into the resumed `.jsonl`. claude dedups an incoming prompt + /// whose `uuid` already exists in the tree → it runs NO turn (the "reopen an old + /// conversation, send a message, nothing happens" stall). A per-instance random + /// prefix makes every resume mint a fresh uuid namespace, so no collision. + msg_id_prefix: String, + /// 009 R4b: flush-engine reentrancy guard. The head-of-queue Held message is + /// dispatched by EITHER the send-time try-flush OR a background rising-edge + /// flush; this serializes them so the same head is never dispatched twice + /// (PC-FLUSH-RACE-ESC-14). Held across the await of a single dispatch. + flush_lock: Mutex<()>, +} + +#[derive(Default)] +struct Pending { + next_id: u64, + queue: Vec, +} + +impl ConversationSession { + /// Open a façade over an orchestrated backend. Capabilities are read on demand + /// from the backend (§5.5 G4 — NOT frozen at open), so async discovery (codex + /// model/list, ACP initialize authMethods) that lands AFTER open is reflected. + pub fn new( + session_id: impl Into, + orchestrator: Arc, + backend: Arc, + ) -> Self { + // A fresh random prefix per instance. A resumed conversation gets a NEW + // ConversationSession (the counter restarts at 0); without a per-instance + // namespace the minted `m-1`, `m-2`… would re-collide with the uuids the + // prior run persisted into claude's resume message-tree, and claude would + // dedup our new prompt as already-seen → no turn (see `msg_id_prefix`). + let prefix = format!("m{}", uuid::Uuid::new_v4().simple()); + Self::with_msg_id_prefix(session_id, orchestrator, backend, prefix) + } + + /// Like [`new`], but with an explicit `client_msg_id` prefix. Tests pin a stable + /// prefix (`"m"`) so the minted ids are the readable `m-1`, `m-2`, … they assert + /// on; production uses [`new`], which mints a random per-instance prefix to keep + /// the wire `uuid` namespace distinct across resume. + pub fn with_msg_id_prefix( + session_id: impl Into, + orchestrator: Arc, + backend: Arc, + msg_id_prefix: impl Into, + ) -> Self { + Self { + session_id: session_id.into(), + orchestrator, + backend, + pending: Mutex::new(Pending::default()), + msg_id_prefix: msg_id_prefix.into(), + flush_lock: Mutex::new(()), + } + } + + /// The stable logical session id (the demux key). + pub fn session_id(&self) -> &str { + &self.session_id + } + + // ====================================================================== + // DOWNWARD — Commands (conversation → session) + // ====================================================================== + + /// Send user content (§3). MUST push a `PendingMessage{client_msg_id}` to the + /// LOCAL pending queue first, then dispatch with that id; the queue drains on + /// the matching `PromptAccepted{client_msg_id}` (see `subscribe_events`). + /// NEVER blocks on turn completion — the turn flows up `subscribe_events`. + pub async fn send(&self, content: Vec) -> Result { + // §2: enqueue Held FIRST (the user may type any time, regardless of + // can_send/can_queue). Mint the correlation id + a monotonic ordinal. + { + let mut pending = self.pending.lock().await; + pending.next_id += 1; + let ordinal = pending.next_id; + let id = format!("{}-{ordinal}", self.msg_id_prefix); + pending.queue.push(PendingMessage { + client_msg_id: id, + content, + status: MsgStatus::Held, + enqueue_ordinal: ordinal, + }); + } + // Then try to flush the head NOW: if the current snapshot already permits + // (can_send || can_queue, or pre-first-transition initial Idle), the head + // dispatches immediately and we return its real receipt. If a turn is in + // flight / blocked on a permission (probeD: claude ignores stdin in RA), + // the message stays Held and the BACKGROUND flush engine dispatches it on + // the next can_send/can_queue rising edge (T7b: "Esc → Held auto-flushes"). + // Either way no message is ever lost — it lives in the permanent queue. + match self.try_flush_head().await { + Some(receipt) => receipt, + // Stayed Held (gate closed). Report Queued — the conv queue is the + // authority (§2); the receipt is just an ack, not a dispatch result. + None => Ok(CommandReceipt { + accepted: true, + admission: Admission::Queued, + turn_gen: self + .orchestrator + .latest_snapshot(&self.session_id) + .map(|s| s.turn_gen) + .unwrap_or(0), + }), + } + } + + /// 009 R4b: the flush engine's core step. Dispatch the lowest-ordinal `Held` + /// message IF the current snapshot permits (`can_send || can_queue`; a missing + /// snapshot = pre-first-transition initial Idle ⇒ can_send). One head at a + /// time, behind `flush_lock` so a send-time flush and a background rising-edge + /// flush never dispatch the same head twice (PC-FLUSH-RACE-ESC-14). Returns + /// the dispatched message's receipt, or `None` if nothing was dispatched (no + /// Held head, or the gate is closed → message stays Held for a later edge). + async fn try_flush_head(&self) -> Option> { + // The send-time path: an outstanding un-accepted dispatch this window closes the + // gate (bug-hunt #8, see `try_flush_head_inner`). + self.try_flush_head_inner(false).await + } + + /// Core flush step. `from_rising_edge` distinguishes the two callers: + /// - `send()` (false): in the PRE-FOLD window `latest_snapshot()` is None → + /// gate_open=true even though a prior send this window already dispatched a turn + /// (TurnStarted lowered async, not yet folded to Running). A second send would + /// then double-dispatch into the in-flight turn (bug-hunt #8: two TurnStarted, + /// turn_gen bumped twice, two prompts to the CLI). So the send-time path ALSO + /// holds if any message is already `Sent` (dispatched, awaiting PromptAccepted): + /// that Sent IS the turn just opened — wait for it. + /// - `run_flush_engine` (true): only fires on a real snapshot FALSE→TRUE rising + /// edge, which means the prior turn actually folded (Running→terminal) — so + /// dispatching the next head is the documented one-head-per-edge contract, and + /// the Sent-outstanding guard must NOT apply (a prior Sent awaiting its + /// PromptAccepted is normal here and the next edge legitimately advances). + async fn try_flush_head_inner(&self, from_rising_edge: bool) -> Option> { + let _flush = self.flush_lock.lock().await; + // Gate: read the pre-derived snapshot — NEVER recompute (§C7). No snapshot + // yet ⇒ initial Idle ⇒ can_send true. + let gate_open = match self.orchestrator.latest_snapshot(&self.session_id) { + Some(s) => s.can_send || s.can_queue, + None => true, + }; + if !gate_open { + return None; + } + // Pick the head = lowest-ordinal Held entry; clone what dispatch needs. + let (client_msg_id, content) = { + let pending = self.pending.lock().await; + // Bug-hunt #8: send-time, with a turn already dispatched-but-unconfirmed in + // this pre-fold window, hold — do not open a second concurrent turn. + if !from_rising_edge && pending.queue.iter().any(|m| m.status == MsgStatus::Sent) { + return None; + } + let head = pending + .queue + .iter() + .filter(|m| m.status == MsgStatus::Held) + .min_by_key(|m| m.enqueue_ordinal)?; + (head.client_msg_id.clone(), head.content.clone()) + }; + let meta = CommandMeta { + client_msg_id: Some(client_msg_id.clone()), + ..Default::default() + }; + // Dispatch. Success ⇒ Sent (awaiting PromptAccepted); failure ⇒ Error so + // the UI bubble rolls back instead of hanging Held forever (PC-ERROR-7). + let result = self + .orchestrator + .send(self.backend.as_ref(), &self.session_id, content, meta) + .await; + self.mark_status( + &client_msg_id, + if result.is_ok() { + MsgStatus::Sent + } else { + MsgStatus::Error + }, + ) + .await; + Some(result) + } + + /// Test-only: drive the rising-edge flush path (what `run_flush_engine` invokes on + /// a real snapshot FALSE→TRUE edge). Tests that SIMULATE an edge (seed an open + /// snapshot then flush) must use this, not the public send-time `try_flush_head` + /// (whose #8 Sent-outstanding guard correctly blocks a second same-window dispatch). + #[cfg(test)] + async fn try_flush_on_edge_for_test(&self) -> Option> { + self.try_flush_head_inner(true).await + } + + /// 009 R4b: run the background flush engine until the snapshot stream ends. + /// Subscribes to `StateSnapshot` and, on a `can_send || can_queue` FALSE→TRUE + /// RISING EDGE (a turn ended / a permission resolved), flushes the head Held + /// message. This is what makes T7b work: a message typed during a turn stays + /// Held, then auto-dispatches as the next turn the moment the turn finishes — + /// no user action needed. Rising-edge (not level) + the flush_lock together + /// prevent re-dispatching on every snapshot or racing the send-time flush. + /// Spawn this once per session (the conversation owns the task handle). + pub async fn run_flush_engine(self: Arc) { + use futures_util::StreamExt as _; + let mut snaps = self.orchestrator.subscribe_state(self.session_id.clone()); + let mut prev_open = false; + while let Some(s) = snaps.next().await { + let open = s.can_send || s.can_queue; + if open && !prev_open { + // Rising edge → dispatch EXACTLY ONE head (the lowest-ordinal Held). + // NOT a drain-all loop: dispatching one opens a turn (state → Running), + // and the next Held waits for the NEXT rising edge. Draining the whole + // queue on a single edge would overrun the backend's FIFO window / + // reorder (§12.6.10 "dispatch only one queue head at a time", PC-MS-13). The remaining + // Held heads flush on subsequent edges as each turn completes. This is a + // real snapshot edge (prior turn folded), so pass from_rising_edge=true: + // the #8 Sent-outstanding guard does not apply here. + let _ = self.try_flush_head_inner(true).await; + } + prev_open = open; + } + } + + /// Set the status of an outstanding message by id (no-op if absent). The flush + /// engine / send path / drain path all funnel status changes through here. + async fn mark_status(&self, client_msg_id: &str, status: MsgStatus) { + let mut pending = self.pending.lock().await; + if let Some(m) = pending.queue.iter_mut().find(|m| m.client_msg_id == client_msg_id) { + m.status = status; + } + } + + /// T7 (009 R4): cancel a single OUTSTANDING message that has NOT been + /// dispatched yet (`Held`). This is a pure conversation-local removal — the + /// backend never received the bytes, so NO wire traffic (no Cancel{Turn}) + /// must be issued. Returns true if a Held entry was removed. + /// + /// ⚠️ A `Sent`/`Accepted` message has already been dispatched (bytes are in + /// the backend's stdin / a turn is running); it MUST be cancelled via + /// `cancel(CancelTarget::Turn)` (T8), never locally dropped — a local delete + /// would leave the backend running a turn with no bubble (ghost turn, + /// PC-RACE-5). This method refuses to touch a non-Held entry. + pub async fn cancel_held(&self, client_msg_id: &str) -> bool { + let mut pending = self.pending.lock().await; + if let Some(pos) = pending + .queue + .iter() + .position(|m| m.client_msg_id == client_msg_id && m.status == MsgStatus::Held) + { + pending.queue.remove(pos); + true + } else { + false + } + } + + /// T7c (009 R4): session-level teardown of the conv queue. Marks EVERY + /// non-terminal outstanding message (`Held`/`Sent`) `Canceled` so the flush + /// engine will never dispatch them again and the UI rolls every pending bubble + /// back. Returns how many were canceled. This is the conv-queue half of + /// `close_session` (the delete hook also kills the backend + clears the + /// roster); it does NOT issue per-turn wire interrupts — process death (kill) + /// is covered by `Detached`. Idempotent: re-running finds nothing non-terminal. + /// + /// ⚠️ This is the SESSION-CLOSE semantic, NOT `Cancel{Turn}` (T7b): Cancel{Turn} + /// (Esc) leaves Held in place to auto-flush; only a session teardown clears the + /// whole queue. + pub async fn cancel_all_outstanding(&self) -> usize { + let mut pending = self.pending.lock().await; + let mut n = 0; + for m in pending.queue.iter_mut() { + if matches!(m.status, MsgStatus::Held | MsgStatus::Sent) { + m.status = MsgStatus::Canceled; + n += 1; + } + } + n + } + + /// Cancel (§3). `Turn`/`Session` accepted by all backends; `Tool` only where + /// `caps.supported_commands.cancel_tool` — else the dispatch returns + /// `CommandNotSupported` (surfaced to the user, never silently dropped). + pub async fn cancel(&self, target: CancelTarget) -> Result { + self.orchestrator + .cancel(self.backend.as_ref(), &self.session_id, target) + .await + } + + /// Answer a permission the backend raised (§3). `request_id` MUST be the + /// upward `Permission{request_id}` (the control-correlation key — NOT a + /// tool_use_id, §9 U10). `selected` is the chosen option label/id for a single + /// pick-one prompt (claude single-question AskUserQuestion / ACP optionId); pass + /// `None` for a plain allow/deny. `answers` carries the FULL claude + /// `AskUserQuestion` set (multi-question / multi-select, task #83); pass an empty + /// vec for non-claude backends or the single-question degrade. + pub async fn answer_permission( + &self, + request_id: String, + decision: PermissionDecision, + selected: Option, + answers: Vec, + ) -> Result { + self.backend + .dispatch(super::types::Command::AnswerPermission { + request_id, + decision, + selected, + answers, + }) + .await + } + + /// Gap #8: switch the backend's mode (§3 / Addendum 7). Forwards + /// `Command::SetMode` to the backend, which confirms it non-optimistically via + /// an upward `ConfigChanged{mode}` (config is orthogonal — the FSM/can_send is + /// untouched). Gated up-front by `can_set_mode()`; an unsupported backend + /// rejects with `BackendError::CommandNotSupported` (never silently dropped). + pub async fn set_mode(&self, mode: String) -> Result { + self.backend.dispatch(super::types::Command::SetMode { mode }).await + } + + /// Gap #8: switch the backend's model (§3 / Addendum 7). Forwards + /// `Command::SetModel`; confirmed via upward `ConfigChanged{model}`. Gated + /// up-front by `can_set_model()`; unsupported → `CommandNotSupported`. + pub async fn set_model(&self, model: String) -> Result { + self.backend.dispatch(super::types::Command::SetModel { model }).await + } + + /// #99: set a generic backend config option (e.g. effort → claude + /// `apply_flag_settings{effortLevel}`). Forwards `Command::SetConfigOption`; + /// an unsupported option / backend rejects with `CommandNotSupported` (never + /// silently dropped). The backend confirms via its own channel (claude effort is + /// read back via get_settings; there is no mode/model-style `ConfigChanged`). + pub async fn set_config_option(&self, option_id: String, value: String) -> Result { + self.backend + .dispatch(super::types::Command::SetConfigOption { option_id, value }) + .await + } + + /// Bug-hunt #2: read-only cumulative session-info query (context-usage / cost). + /// Forwards `Command::QuerySessionInfo`; the answer flows back via the + /// SessionEvent::SessionInfo stream (not this receipt). `kind` is the + /// conversation-side string ("context_usage" | "session_cost"); an unknown kind + /// defaults to ContextUsage. Backends that don't advertise it reject (surfaced up). + pub async fn query_session_info(&self, kind: &str) -> Result { + use super::types::SessionInfoKind; + let kind = match kind { + "session_cost" | "cost" => SessionInfoKind::SessionCost, + _ => SessionInfoKind::ContextUsage, + }; + self.backend + .dispatch(super::types::Command::QuerySessionInfo { kind }) + .await + } + + // ====================================================================== + // UPWARD — subscriptions (session → conversation), all demuxed by session_id + // ====================================================================== + + /// Raw `SessionEnvelope` stream, demuxed to this session (transcript / + /// streaming UI — every delta, Tier-0 push-not-store). The conversation MUST + /// inspect `PromptAccepted{client_msg_id}` on this stream to drain pending — + /// `drain_pending_on` does that, kept separate so the stream stays a pure + /// projection. + pub fn subscribe_events(&self) -> BoxStream<'static, SessionEnvelope> { + self.orchestrator.subscribe_events(self.session_id.clone()) + } + + /// Full `StateSnapshot` stream, demuxed to this session (§9.12 — every change + /// is a FULL snapshot, never incremental). + pub fn subscribe_state(&self) -> BoxStream<'static, StateSnapshot> { + self.orchestrator.subscribe_state(self.session_id.clone()) + } + + /// The unlock stream: `StateSnapshot.can_send` (PRE-DERIVED by the + /// orchestrator, §3 line 728). The conversation reads this bool — it MUST NOT + /// recompute `can_send_message(state)`. + pub fn subscribe_unlock(&self) -> BoxStream<'static, bool> { + self.orchestrator.subscribe_unlock(self.session_id.clone()) + } + + /// The current `Capabilities` (G4: read on demand from the backend, NOT a + /// frozen open-time snapshot). `backend.capabilities()` is a cheap sync + /// read-only merge, so a model/list or initialize-authMethods response that + /// lands after open is reflected on the next read (the empty-switcher gap fix). + pub fn capabilities(&self) -> Capabilities { + self.backend.capabilities() + } + + /// Read-only passthrough of the backend's currently-open (unanswered) permission + /// requests, for REST recovery (`GET /confirmations`) of a reloaded + /// `waiting_confirmation` conversation. Pure sync read, no await/spawn — same + /// discipline as `capabilities`/`live_snapshot`. Backends without a pending + /// registry return empty (the trait default). + pub fn pending_permission_requests(&self) -> Vec { + self.backend.pending_permission_requests() + } + + /// This session's LATEST cached `StateSnapshot` (the same lag-recovering cache + /// `subscribe_state` re-emits on `Lagged`), or `None` before the first + /// transition. A synchronous read for callers that must correlate an action to + /// the live turn — e.g. a turn-targeted cancel that no-ops unless the live + /// `turn_gen` matches and `can_cancel` is set (Route B s9d). Reads the same + /// `orchestrator.latest_snapshot` the internal flush gate consults. + pub fn live_snapshot(&self) -> Option { + self.orchestrator.latest_snapshot(&self.session_id) + } + + /// This session's STICKY last-terminal `(turn_gen, TransitionReason)` — the + /// lag-recovering terminal oracle (the orchestrator's `latest_terminal` cache). + /// `None` before any turn settled. A stall-intolerant consumer that may have + /// missed the live terminal (a `Lagged` drop on the domain ring) reads this to + /// recover the most-recently-settled turn's outcome; the `turn_gen` confirms + /// WHICH turn settled. + pub fn latest_terminal(&self) -> Option<(u64, super::types::TransitionReason)> { + self.orchestrator.latest_terminal(&self.session_id) + } + + /// Re-deliver current truth after a transport drop / `Lagged` (§9.3 / C7). + /// This is an ORCHESTRATION SIGNAL, NOT a `Command` — it never reaches a + /// backend or the FSM. The conversation calls it on a `Lagged` or WS reconnect; + /// the orchestrator re-broadcasts this session's cached StateSnapshot (so + /// `subscribe_state`/`subscribe_unlock` immediately re-see the current + /// can_send + full FSM) and emits a `Snapshot` envelope on the event stream. + /// P0 = re-emit cached truth; Tier-1 transcript backfill + live-tail-resume are + /// the deferred P2 slice. + pub async fn reconnect(&self) { + self.orchestrator.reconnect(&self.session_id).await; + } + + // ====================================================================== + // Pending-queue management (§9.11 / Addendum 3) + // ====================================================================== + + /// Drain pending on a `PromptAccepted{client_msg_id}` envelope. The + /// conversation calls this for each event it observes on `subscribe_events`; + /// it removes EXACTLY the matching pending entry (others stay). Returns the + /// drained message if one matched. MUST be driven by `PromptAccepted`, NOT the + /// optimistic `TurnStarted` (which would drain a not-yet-confirmed message). + pub async fn drain_pending_on(&self, env: &SessionEnvelope) -> Option { + let crate::event::SessionEvent::PromptAccepted { client_msg_id } = &env.event else { + return None; + }; + // §2: the queue is a PERMANENT record (Held→Sent→Accepted), so a + // PromptAccepted marks the matching entry `Accepted` (optimistic bubble + // confirmed) — it does NOT remove it. Returns the now-Accepted message if + // one matched (drives the bubble's sending→sent transition). Precise + // single-id match: a stale/unknown id drains nothing (PC-MS-9 / never the + // optimistic TurnStarted, which would confirm a not-yet-accepted message). + let mut pending = self.pending.lock().await; + let m = pending.queue.iter_mut().find(|m| &m.client_msg_id == client_msg_id)?; + m.status = MsgStatus::Accepted; + Some(m.clone()) + } + + /// A snapshot of the current pending queue (for UI rendering — the queue is + /// conversation-owned state). + pub async fn pending(&self) -> Vec { + self.pending.lock().await.queue.clone() + } + + // ====================================================================== + // Layer-1 UP-FRONT capability gating (§5.2) — proactive affordance hiding + // ====================================================================== + + pub fn can_steer(&self) -> bool { + self.backend.capabilities().supported_commands.steer + } + pub fn accepts_images(&self) -> bool { + self.backend.capabilities().prompt_blocks.image + } + /// Whether the backend accepts file attachments (`ResourceLink`): claude (Read + /// tool path-ref) + ACP (native resource_link) + codex (`UserInput::Mention` + /// @file by path) = true; aionrs = false. The UI gates the file-picker on this + /// (additive parity with accepts_images). + pub fn accepts_files(&self) -> bool { + self.backend.capabilities().prompt_blocks.resource + } + pub fn can_rewind(&self) -> bool { + self.backend.capabilities().supported_commands.rewind + } + pub fn can_set_mode(&self) -> bool { + self.backend.capabilities().supported_commands.set_mode + } + pub fn can_set_model(&self) -> bool { + self.backend.capabilities().supported_commands.set_model + } + pub fn available_auth_methods(&self) -> Vec { + self.backend.capabilities().auth_methods + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::CodexSessionBackend; + use crate::event::SessionEvent; + use crate::testing::FakeAgentIo; + + /// Build a ConversationSession over a real (fake-io) codex backend + a fresh + /// orchestrator. The backend binds threadId from a thread/started prefix. + async fn facade(session_id: &str) -> (ConversationSession, Arc, Arc) { + let prefix = format!( + "{}\n", + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-x"}}}"# + ) + .into_bytes(); + let fake = FakeAgentIo::new(prefix, None); + let backend: Arc = + Arc::new(CodexSessionBackend::build_with_io(session_id, Box::new(fake)).await); + let orch = Arc::new(Orchestrator::new(256)); + // Pin the prefix to "m" so minted ids are the readable m-1, m-2, … asserted below. + let convo = ConversationSession::with_msg_id_prefix(session_id, orch.clone(), backend.clone(), "m"); + (convo, orch, backend) + } + + #[tokio::test] + async fn caps_read_once_drive_up_front_gating() { + let (convo, _orch, _backend) = facade("s1").await; + // codex caps (the matrix): steer/set_mode/set_model true, image true. + assert!(convo.can_steer(), "codex supports steer"); + // G3: rewind = true — the seam now wires thread/rollback (down) + Rewound + // (up), so the conversation may surface a rewind/fork affordance. + assert!(convo.can_rewind(), "codex rewind is wired (G3)"); + assert!(convo.can_set_mode()); + assert!(convo.can_set_model()); + assert!(convo.accepts_images()); + assert!( + convo + .available_auth_methods() + .contains(&"chatgptAuthTokens".to_string()), + "codex advertises mid-session auth methods" + ); + } + + /// ENUMERATION INVARIANT (capability forwarding). The 7 `can_*`/`accepts_*` getters + /// are thin forwards onto `backend.capabilities()`. `caps_read_once...` asserts + /// codex's HARDCODED values — but that wouldn't catch a getter wired to the WRONG + /// field (e.g. `can_set_mode()` reading `.set_model`). This pins every getter == + /// its exact source field, across TWO backends (claude + codex) so the forwarding + /// is proven structurally, not against one backend's happens-to-be values. + /// (Limit: claude & codex both have set_mode==set_model==true, so a getter wired to + /// the wrong-but-equal field is data-invisible here — steer/rewind/image DO differ + /// claude-vs-codex, so the forwarding itself is proven non-vacuous via those.) + #[tokio::test] + async fn capability_getters_mirror_backend_capabilities_exactly() { + for session_id in ["fwd-codex", "fwd-claude"] { + let backend: Arc = if session_id == "fwd-codex" { + let prefix = format!( + "{}\n", + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-y"}}}"# + ) + .into_bytes(); + Arc::new(CodexSessionBackend::build_with_io(session_id, Box::new(FakeAgentIo::new(prefix, None))).await) + } else { + Arc::new( + crate::backend::ClaudeSessionBackend::build_with_io( + session_id, + Box::new(FakeAgentIo::never_exits(Vec::new())), + ) + .await, + ) + }; + let convo = ConversationSession::new(session_id, Arc::new(Orchestrator::new(64)), backend.clone()); + let caps = backend.capabilities(); + + assert_eq!( + convo.can_steer(), + caps.supported_commands.steer, + "[{session_id}] can_steer" + ); + assert_eq!( + convo.can_rewind(), + caps.supported_commands.rewind, + "[{session_id}] can_rewind" + ); + assert_eq!( + convo.can_set_mode(), + caps.supported_commands.set_mode, + "[{session_id}] can_set_mode" + ); + assert_eq!( + convo.can_set_model(), + caps.supported_commands.set_model, + "[{session_id}] can_set_model" + ); + assert_eq!( + convo.accepts_images(), + caps.prompt_blocks.image, + "[{session_id}] accepts_images" + ); + assert_eq!( + convo.accepts_files(), + caps.prompt_blocks.resource, + "[{session_id}] accepts_files" + ); + assert_eq!( + convo.available_auth_methods(), + caps.auth_methods, + "[{session_id}] available_auth_methods" + ); + } + } + + /// 🖥️ R9 (caps-freeze vs async discovery race) — G4 FIX. The façade no longer + /// freezes caps at open; `capabilities()` passes through to + /// `backend.capabilities()` on demand. codex fills `available_models` + /// ASYNCHRONOUSLY from the model/list response (fire-and-forget at + /// open_session). Before G4, a response landing after `new()` was invisible to + /// the façade (the empty-model-switcher gap). Now the façade reflects it on the + /// next read: open with NO model → drive discovery → the façade DOES see it. + #[tokio::test] + async fn r9_conversation_session_caps_reflect_late_discovery() { + // codex backend with a model/list response queued (id=50) but not yet read. + let model_resp = r#"{"jsonrpc":"2.0","id":50,"result":{"models":[{"id":"gpt-5.5","displayName":"GPT-5.5"}]}}"#; + let fake = FakeAgentIo::never_exits(format!("{model_resp}\n").into_bytes()); + let backend_concrete = CodexSessionBackend::build_with_io("s-r9", Box::new(fake)).await; + // Register the pending discovery id the reader will claim (open_session does + // this after the handshake; build_with_io skips it). + backend_concrete.register_model_discovery_for_test(50).await; + let backend: Arc = Arc::new(backend_concrete); + let orch = Arc::new(Orchestrator::new(256)); + + // Open the façade BEFORE the reader processes the model/list response → at + // this instant the (on-demand) caps still have an empty model list. + let convo = ConversationSession::new("s-r9", orch, backend.clone()); + assert!( + convo.capabilities().available_models.is_empty(), + "before discovery the on-demand caps still have no model" + ); + + // Drive discovery: subscribing to events() runs the reader, which claims the + // model/list response and fills `discovered`. Poll the FAÇADE caps directly. + let _ev = backend.events(); + let mut facade_has_model = false; + for _ in 0..40 { + if !convo.capabilities().available_models.is_empty() { + facade_has_model = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + // THE G4 FIX: the façade's on-demand caps now reflect the late discovery — + // a model/list response landing AFTER new() IS visible to the UI switcher. + assert!( + facade_has_model, + "R9/G4: ConversationSession::capabilities() reads through to the backend on \ + demand, so a model/list response that lands AFTER new() IS reflected \ + (the empty-model-switcher gap is fixed)" + ); + } + + /// Regression: the "reopen an old conversation, send a message, nothing happens" + /// stall. The minted `client_msg_id` IS the wire-frame `uuid` claude stores in its + /// resume message-tree. A resumed conversation gets a FRESH ConversationSession + /// (the ordinal counter restarts at 0), so a purely ordinal id (`m-1`, `m-2`, …) + /// re-collides with the uuids the PRIOR run already persisted — claude dedups the + /// incoming prompt as already-seen and runs no turn. Two instances of the SAME + /// conversation must therefore mint DISJOINT id namespaces. Live-proven: stamping a + /// colliding uuid stalls `--resume` 5/5; a unique one produces a turn. + #[tokio::test] + async fn resumed_instance_mints_disjoint_client_msg_ids() { + // Build a fake-io backend for a logical conversation (helper mirrors `facade`). + async fn backend(session_id: &str) -> Arc { + let prefix = format!( + "{}\n", + r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-x"}}}"# + ) + .into_bytes(); + let fake = FakeAgentIo::new(prefix, None); + Arc::new(CodexSessionBackend::build_with_io(session_id, Box::new(fake)).await) + } + // Two independent instances of the SAME logical conversation (= a restart / + // resume), each built via the production `new` (random per-instance prefix). + let first = ConversationSession::new("conv-x", Arc::new(Orchestrator::new(64)), backend("conv-x").await); + let second = ConversationSession::new("conv-x", Arc::new(Orchestrator::new(64)), backend("conv-x").await); + + first.send(vec![ContentBlock::Text("a".into())]).await.ok(); + second.send(vec![ContentBlock::Text("a".into())]).await.ok(); + + let id1 = first.pending().await[0].client_msg_id.clone(); + let id2 = second.pending().await[0].client_msg_id.clone(); + assert_ne!( + id1, id2, + "two instances of the same conversation must mint DISJOINT client_msg_ids \ + (else a resume re-emits a uuid already in claude's tree → dedup → no turn)" + ); + } + + #[tokio::test] + async fn send_pushes_pending_with_threaded_client_msg_id() { + let (convo, _orch, _backend) = facade("s1").await; + let receipt = convo + .send(vec![ContentBlock::Text("hi".into())]) + .await + .expect("send accepted"); + assert!(receipt.accepted); + // The local pending queue holds exactly the one message, with a minted id. + let pending = convo.pending().await; + assert_eq!(pending.len(), 1, "one message queued"); + assert_eq!(pending[0].client_msg_id, "m-1", "client_msg_id minted + threaded"); + assert_eq!(pending[0].content, vec![ContentBlock::Text("hi".into())]); + } + + #[tokio::test] + async fn prompt_accepted_marks_exactly_the_matching_pending_accepted() { + // 009 R4/§2: the queue is a PERMANENT record. A PromptAccepted{m-1} marks + // EXACTLY m-1 Accepted; m-2 keeps its prior status. Both entries stay in the + // queue (precise single-id match, never an optimistic multi-drain). + // NOTE (#8): in this None-snapshot window only the FIRST send dispatches (→Sent); + // the second stays Held (the pre-fold double-dispatch guard) — it auto-flushes on + // the next rising edge. m-1 dispatched, so PromptAccepted{m-1} is the case here. + let (convo, _orch, _backend) = facade("s1").await; + convo.send(vec![ContentBlock::Text("first".into())]).await.expect("ok"); + convo.send(vec![ContentBlock::Text("second".into())]).await.expect("ok"); + assert_eq!(convo.pending().await.len(), 2); + + let env = SessionEnvelope { + session_id: "s1".into(), + turn_gen: 1, + event: SessionEvent::PromptAccepted { + client_msg_id: "m-1".into(), + }, + }; + let drained = convo.drain_pending_on(&env).await; + assert_eq!(drained.map(|m| m.client_msg_id), Some("m-1".to_string())); + let pending = convo.pending().await; + assert_eq!(pending.len(), 2, "permanent record: both entries remain"); + let m1 = pending.iter().find(|m| m.client_msg_id == "m-1").unwrap(); + let m2 = pending.iter().find(|m| m.client_msg_id == "m-2").unwrap(); + assert_eq!(m1.status, MsgStatus::Accepted, "exactly the matched entry is Accepted"); + assert_eq!( + m2.status, + MsgStatus::Held, + "the other entry keeps its prior status — Held, NOT dispatched (#8: only one \ + head dispatches per pre-fold window; m-2 auto-flushes on the next edge)" + ); + } + + /// Bug-hunt #2: ConversationSession::query_session_info forwards + /// Command::QuerySessionInfo to the backend — making the (previously unreachable) + /// claude get_context_usage / SessionInfo reply pipeline triggerable. Asserts the + /// real wire frame (claude writes control_request{get_context_usage}). + #[tokio::test] + async fn query_session_info_forwards_to_backend_wire() { + use crate::testing::FakeAgentIo; + use crate::{ClaudeSessionBackend, Orchestrator}; + let fake = FakeAgentIo::never_exits(Vec::new()); + let captured = fake.captured_stdin(); + let backend: Arc = + Arc::new(ClaudeSessionBackend::build_with_io("qsi", Box::new(fake)).await); + let convo = ConversationSession::new("qsi", Arc::new(Orchestrator::new(64)), backend); + + let receipt = convo + .query_session_info("context_usage") + .await + .expect("claude advertises it"); + assert!( + receipt.accepted && receipt.admission == Admission::NoTurn, + "read-only query, NoTurn" + ); + + let written = { + let mut s = String::new(); + for _ in 0..40 { + s = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + if s.contains("get_context_usage") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + s + }; + assert!( + written.contains(r#""subtype":"get_context_usage""#), + "#2: query_session_info writes the claude get_context_usage control_request, got: {written}" + ); + } + + /// Bug-hunt #8: two sends in the SAME pre-fold window (latest_snapshot==None, gate + /// open) must NOT both dispatch — the first opens a turn (TurnStarted lowered async, + /// not yet folded to Running), the second would double-fire into the in-flight turn + /// (two TurnStarted, turn_gen bumped twice, two prompts to the CLI). The send-time + /// flush holds the second as Held when a prior Sent is outstanding; it auto-flushes + /// on the next rising edge. (The rising-edge path is exempt — see the FIFO test.) + #[tokio::test] + async fn two_sends_in_prefold_window_dispatch_only_one_head() { + let (convo, _orch, _backend) = facade("s1").await; // NO snapshot seeded = None window + convo.send(vec![ContentBlock::Text("a".into())]).await.expect("ok"); + convo.send(vec![ContentBlock::Text("b".into())]).await.expect("ok"); + let q = convo.pending().await; + let by_id = |id: &str| q.iter().find(|m| m.client_msg_id == id).unwrap().status; + assert_eq!(by_id("m-1"), MsgStatus::Sent, "first send opens the turn (dispatched)"); + assert_eq!( + by_id("m-2"), + MsgStatus::Held, + "#8: the second send in the pre-fold window must NOT double-dispatch — stays Held" + ); + // A later rising edge (prior turn folded) dispatches m-2 — no message lost. + convo.orchestrator.seed_latest_for_test(snap("s1", true, false)); + let r = convo.try_flush_on_edge_for_test().await; + assert!(matches!(r, Some(Ok(_))), "m-2 auto-flushes on the next rising edge"); + assert_eq!( + convo + .pending() + .await + .iter() + .find(|m| m.client_msg_id == "m-2") + .unwrap() + .status, + MsgStatus::Sent + ); + } + + #[tokio::test] + async fn send_marks_held_then_sent() { + // 009 R4/§2: a successful send enqueues Held then (dispatch ok) marks Sent. + let (convo, _orch, _backend) = facade("s1").await; + convo.send(vec![ContentBlock::Text("hi".into())]).await.expect("ok"); + let pending = convo.pending().await; + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].status, MsgStatus::Sent, "dispatched ok → Sent"); + assert_eq!(pending[0].enqueue_ordinal, 1, "monotonic ordinal minted"); + } + + #[tokio::test] + async fn cancel_held_drops_only_undispatched_and_never_a_sent_entry() { + // 009 R4 / T7: cancel_held removes a Held entry (conv-local, no wire) but + // REFUSES a Sent/Accepted entry (already dispatched → must go through + // Cancel{Turn}/T8, never a local delete that would leave a ghost turn). + let (convo, _orch, _backend) = facade("s1").await; + // m-1 dispatched (Sent); inject a second entry that stays Held by mutating + // status directly (simulates "enqueued, flush engine has not dispatched yet"). + convo.send(vec![ContentBlock::Text("a".into())]).await.expect("ok"); + convo.send(vec![ContentBlock::Text("b".into())]).await.expect("ok"); + convo.mark_status("m-2", MsgStatus::Held).await; // force m-2 back to Held (un-dispatched) + + // T7: cancel the Held m-2 → removed, no wire. + assert!(convo.cancel_held("m-2").await, "Held entry removed"); + let pending = convo.pending().await; + assert!(!pending.iter().any(|m| m.client_msg_id == "m-2"), "m-2 gone"); + + // T8 guard: cancel_held must REFUSE the Sent m-1 (it's dispatched). + assert!( + !convo.cancel_held("m-1").await, + "Sent entry must NOT be locally dropped" + ); + assert!( + convo.pending().await.iter().any(|m| m.client_msg_id == "m-1"), + "m-1 (Sent) stays — cancel it via Cancel{{Turn}} (T8), not a local delete" + ); + } + + #[tokio::test] + async fn cancel_all_outstanding_clears_queue_for_session_teardown() { + // 009 R4 / T7c: session teardown marks every non-terminal entry Canceled + // (NOT a per-turn Esc — that's T7b which leaves Held to auto-flush). After + // it, the flush engine finds no Held head to dispatch, and the count + // reflects exactly the non-terminal entries. Idempotent. + let (convo, _orch, _backend) = facade("s1").await; + convo.send(vec![ContentBlock::Text("a".into())]).await.expect("ok"); // → Sent + convo.send(vec![ContentBlock::Text("b".into())]).await.expect("ok"); + convo.mark_status("m-2", MsgStatus::Held).await; // m-2 un-dispatched + let n = convo.cancel_all_outstanding().await; + assert_eq!(n, 2, "both the Sent and the Held entry are canceled"); + let pending = convo.pending().await; + assert!( + pending.iter().all(|m| m.status == MsgStatus::Canceled), + "every entry is Canceled after teardown, got {pending:?}" + ); + // try_flush_head finds no Held head → nothing dispatches. + assert!( + convo.try_flush_head().await.is_none(), + "no Held head to flush after teardown" + ); + assert_eq!( + convo.cancel_all_outstanding().await, + 0, + "idempotent: nothing non-terminal left" + ); + } + + /// Build a StateSnapshot with chosen gate values (009 R4b flush tests). + fn snap(sid: &str, can_send: bool, can_queue: bool) -> StateSnapshot { + StateSnapshot { + session_id: sid.into(), + state: if can_send { + crate::state::SessionState::Idle + } else { + crate::state::SessionState::Running { + since_epoch: 1, + saw_substantive_output: false, + terminal_result_seen: false, + requires_action: crate::state::RequiresActionSet { + waiting_on_approval: if can_queue { 0 } else { 1 }, + waiting_on_auth: 0, + }, + subagents: Vec::new(), + } + }, + can_send, + has_activity: !can_send, + can_queue, + can_cancel: !can_send, + turn_gen: 1, + last_reason: None, + } + } + + #[tokio::test] + async fn send_while_gate_closed_stays_held_no_dispatch() { + // 009 R4b / probeD: when can_send=false AND can_queue=false (a turn blocked + // on a permission — RA), a send must NOT dispatch (claude ignores stdin in + // RA → a blind write is swallowed). The message stays Held for a later + // rising edge; the receipt reports Queued (the conv queue is the authority). + let (convo, orch, _backend) = facade("s1").await; + orch.seed_latest_for_test(snap("s1", false, false)); // gate closed + let receipt = convo.send(vec![ContentBlock::Text("held".into())]).await.expect("ok"); + assert_eq!( + receipt.admission, + Admission::Queued, + "gate closed → Queued, not dispatched" + ); + let pending = convo.pending().await; + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].status, MsgStatus::Held, "stayed Held — never dispatched"); + } + + #[tokio::test] + async fn flush_engine_dispatches_held_head_on_rising_edge() { + // 009 R4b / T7b: a message typed while gate closed stays Held; when the + // turn ends (can_send false→true RISING EDGE), the background flush engine + // dispatches the head Held → Sent, no user action. This is "Esc → Held + // auto-flushes as next turn". + let (convo, orch, _backend) = facade("s1").await; + orch.seed_latest_for_test(snap("s1", false, false)); // gate closed + let convo = Arc::new(convo); + convo.send(vec![ContentBlock::Text("queued".into())]).await.expect("ok"); + assert_eq!(convo.pending().await[0].status, MsgStatus::Held, "starts Held"); + + // Spawn the flush engine, then broadcast a rising edge (Idle, can_send=true). + let engine = { + let convo = convo.clone(); + tokio::spawn(async move { convo.run_flush_engine().await }) + }; + // Seed cache so try_flush_head's gate read sees open, then push the edge. + orch.seed_latest_for_test(snap("s1", true, false)); + let _ = orch.state_tx_for_test().send(snap("s1", true, false)); + + // Poll until the head transitions Held → Sent (the engine dispatched it). + let mut dispatched = false; + for _ in 0..40 { + if convo.pending().await.iter().any(|m| m.status == MsgStatus::Sent) { + dispatched = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + engine.abort(); + assert!(dispatched, "flush engine dispatched the Held head on the rising edge"); + } + + /// proactive-queue (audit): the flush gate is `can_send || can_queue` + /// (try_flush_head, §C7). The can_queue disjunct — a turn IS in flight + /// (can_send=false) but the backend accepts proactive input (can_queue=true) — + /// was executed in production but NEVER asserted (every existing snap() is + /// (false,false) or (true,false), never (false,true)). This pins the + /// can_queue-only arm: a Held head dispatches when ONLY can_queue is open. + #[tokio::test] + async fn flush_dispatches_head_when_only_can_queue_open() { + let (convo, orch, _backend) = facade("s1").await; + orch.seed_latest_for_test(snap("s1", false, false)); // gate fully closed + convo.send(vec![ContentBlock::Text("queued".into())]).await.expect("ok"); + assert_eq!( + convo.pending().await[0].status, + MsgStatus::Held, + "stays Held while the gate is fully closed" + ); + // Open the gate via can_queue ONLY (can_send stays false: a turn is in flight, + // no requires_action). The snapshot is opaque gate INPUT — using a codex-backed + // facade is fine; we are testing the gate logic, not the backend's real caps. + orch.seed_latest_for_test(snap("s1", false, true)); + let r = convo.try_flush_head().await; + assert!( + matches!(r, Some(Ok(_))), + "a can_queue-open gate dispatches the Held head, got {r:?}" + ); + assert_eq!( + convo.pending().await[0].status, + MsgStatus::Sent, + "Held → Sent on a can_queue-only flush (proactive in-flight send)" + ); + } + + /// proactive-queue rising-edge: run_flush_engine's edge is `can_send || can_queue` + /// too — a false→true edge driven PURELY by can_queue flipping true must + /// auto-flush the Held head (mirror of flush_engine_dispatches_held_head_on_rising_edge + /// but with the (false,true) snapshot the existing test never exercises). + #[tokio::test] + async fn flush_engine_fires_on_can_queue_rising_edge() { + let (convo, orch, _backend) = facade("s1").await; + orch.seed_latest_for_test(snap("s1", false, false)); + let convo = Arc::new(convo); + convo.send(vec![ContentBlock::Text("q".into())]).await.expect("ok"); + + let engine = { + let convo = convo.clone(); + tokio::spawn(async move { convo.run_flush_engine().await }) + }; + // Seed cache so the engine's gate read sees can_queue open, then push the edge. + orch.seed_latest_for_test(snap("s1", false, true)); + let _ = orch.state_tx_for_test().send(snap("s1", false, true)); + + let mut dispatched = false; + for _ in 0..40 { + if convo.pending().await.iter().any(|m| m.status == MsgStatus::Sent) { + dispatched = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + engine.abort(); + assert!( + dispatched, + "a rising edge driven purely by can_queue (proactive in-flight) auto-flushes the Held head" + ); + } + + /// race-audit conn-13 (PC-FLUSH-RACE-ESC-14): the flush engine is reentrant — + /// a send-time `try_flush_head` and the background rising-edge + /// `run_flush_engine` (which also calls `try_flush_head`) can fire against the + /// SAME lowest-ordinal Held head at the same time. Both production entrypoints + /// funnel through `try_flush_head`, so contending two such calls on one Held + /// head IS the reentrancy test. The `flush_lock` + the in-lock Held→Sent flip + /// must make this dispatch EXACTLY ONCE: one call wins (Some(dispatched)), the + /// other finds no Held head (None). Without the lock, both read `Held` before + /// either marks `Sent` and double-dispatch (two turns opened, FIFO overrun). + #[tokio::test] + async fn concurrent_flush_dispatches_the_held_head_exactly_once() { + let (convo, orch, _backend) = facade("s1").await; + // Enqueue ONE Held head while the gate is closed (so send() does not flush it). + orch.seed_latest_for_test(snap("s1", false, false)); + let convo = Arc::new(convo); + convo.send(vec![ContentBlock::Text("race".into())]).await.expect("ok"); + assert_eq!(convo.pending().await[0].status, MsgStatus::Held, "starts Held"); + assert_eq!(convo.pending().await.len(), 1, "exactly one queued entry"); + + // Open the gate, then fire TWO flushers concurrently at the same Held head + // (the send-time path and the rising-edge path both reach try_flush_head). + orch.seed_latest_for_test(snap("s1", true, false)); + let (a, b) = { + let c1 = convo.clone(); + let c2 = convo.clone(); + tokio::join!(async move { c1.try_flush_head().await }, async move { + c2.try_flush_head().await + },) + }; + + // EXACTLY ONE dispatched (Some), the other found no Held head (None). + let dispatched = [&a, &b].iter().filter(|r| r.is_some()).count(); + let no_head = [&a, &b].iter().filter(|r| r.is_none()).count(); + assert_eq!( + (dispatched, no_head), + (1, 1), + "flush_lock must serialize: one dispatch wins, the other sees the head already Sent → None (got a={a:?}, b={b:?})" + ); + // The winner's dispatch succeeded and the single entry is Sent (once). + assert!(a.or(b).expect("one Some").is_ok(), "the winning dispatch succeeded"); + let pending = convo.pending().await; + assert_eq!(pending.len(), 1, "still exactly one entry (no duplicate enqueued)"); + assert_eq!( + pending.iter().filter(|m| m.status == MsgStatus::Sent).count(), + 1, + "the Held head is dispatched exactly once (Sent), never double-dispatched" + ); + } + + #[tokio::test] + async fn prompt_accepted_for_unknown_id_drains_nothing() { + let (convo, _orch, _backend) = facade("s1").await; + convo.send(vec![ContentBlock::Text("x".into())]).await.expect("ok"); + let env = SessionEnvelope { + session_id: "s1".into(), + turn_gen: 1, + event: SessionEvent::PromptAccepted { + client_msg_id: "m-999".into(), + }, + }; + assert!( + convo.drain_pending_on(&env).await.is_none(), + "no match → nothing drained" + ); + assert_eq!(convo.pending().await.len(), 1, "pending unchanged"); + } + + #[tokio::test] + async fn non_prompt_accepted_event_never_drains() { + let (convo, _orch, _backend) = facade("s1").await; + convo.send(vec![ContentBlock::Text("x".into())]).await.expect("ok"); + // A delta (not PromptAccepted) must NOT drain pending (the optimistic-drain + // anti-pattern, §C7 MUST-NOT). + let env = SessionEnvelope { + session_id: "s1".into(), + turn_gen: 1, + event: SessionEvent::MessageDelta { + item_id: "m1".into(), + text: "hello".into(), + }, + }; + assert!(convo.drain_pending_on(&env).await.is_none()); + assert_eq!(convo.pending().await.len(), 1, "delta does not drain pending"); + } + + #[tokio::test] + async fn subscribe_state_demuxes_to_this_session() { + // A façade for s1 must only see s1 snapshots (cross-session isolation). + let (convo, orch, _backend) = facade("s1").await; + let mut states = convo.subscribe_state(); + // Push a snapshot for ANOTHER session directly; s1's stream must not see it. + let _ = orch.state_tx_for_test().send(StateSnapshot { + session_id: "s2".into(), + state: crate::state::SessionState::Idle, + can_send: true, + has_activity: false, + can_queue: false, + can_cancel: false, + turn_gen: 0, + last_reason: None, + }); + let _ = orch.state_tx_for_test().send(StateSnapshot { + session_id: "s1".into(), + state: crate::state::SessionState::Idle, + can_send: true, + has_activity: false, + can_queue: false, + can_cancel: false, + turn_gen: 0, + last_reason: None, + }); + let first = tokio::time::timeout( + std::time::Duration::from_secs(2), + futures_util::StreamExt::next(&mut states), + ) + .await + .expect("not hang") + .expect("a snapshot"); + assert_eq!(first.session_id, "s1", "only this session's snapshots are delivered"); + } + + #[tokio::test] + async fn live_snapshot_reflects_cached_turn_gen_and_can_cancel() { + // `live_snapshot` is the synchronous read a turn-targeted cancel correlates + // against (Route B s9d): it returns the SAME cached snapshot the orchestrator + // serves, carrying turn_gen + can_cancel. None before any transition. + let (convo, orch, _backend) = facade("s1").await; + assert!(convo.live_snapshot().is_none(), "no snapshot before any transition"); + + // Seed a Running/cancellable snapshot for THIS session (as a real fold would). + orch.seed_latest_for_test(StateSnapshot { + session_id: "s1".into(), + state: crate::state::SessionState::Running { + since_epoch: 7, + saw_substantive_output: false, + terminal_result_seen: false, + requires_action: crate::state::RequiresActionSet::default(), + subagents: Vec::new(), + }, + can_send: false, + has_activity: true, + can_queue: false, + can_cancel: true, + turn_gen: 7, + last_reason: None, + }); + let snap = convo.live_snapshot().expect("snapshot after seed"); + assert_eq!(snap.turn_gen, 7, "live turn_gen is readable for cancel correlation"); + assert!(snap.can_cancel, "a Running turn is cancellable"); + + // A snapshot for ANOTHER session must not leak into this one's read. + orch.seed_latest_for_test(StateSnapshot { + session_id: "s2".into(), + state: crate::state::SessionState::Idle, + can_send: true, + has_activity: false, + can_queue: false, + can_cancel: false, + turn_gen: 99, + last_reason: None, + }); + assert_eq!( + convo.live_snapshot().map(|s| s.turn_gen), + Some(7), + "demuxed to this session" + ); + } + + #[tokio::test] + async fn gap_f_reconnect_reemits_cached_snapshot_and_snapshot_event() { + // GAP-F: reconnect() re-delivers current truth — the cached StateSnapshot on + // the state stream (so a re-subscriber re-sees can_send) AND a Snapshot + // envelope on the event stream (the first producer of that variant). + use crate::event::SessionEvent; + let (convo, orch, _backend) = facade("s1").await; + // Seed the cache as a real fold would (Running, locked). + orch.seed_latest_for_test(StateSnapshot { + session_id: "s1".into(), + state: crate::state::SessionState::Running { + since_epoch: 4, + saw_substantive_output: true, + terminal_result_seen: false, + requires_action: crate::state::RequiresActionSet::default(), + subagents: Vec::new(), + }, + can_send: false, + has_activity: true, + // Running (no RA) → cancellable; can_queue not asserted by this seed + // test (its focus is the locked can_send + cache recovery). + can_queue: false, + can_cancel: true, + turn_gen: 4, + last_reason: None, + }); + // Subscribe AFTER the cache is seeded but the streams are otherwise idle. + let mut states = convo.subscribe_state(); + let mut events = convo.subscribe_events(); + convo.reconnect().await; + + let snap = tokio::time::timeout( + std::time::Duration::from_secs(2), + futures_util::StreamExt::next(&mut states), + ) + .await + .expect("not hang") + .expect("a re-emitted snapshot"); + assert_eq!(snap.session_id, "s1"); + assert!(!snap.can_send, "reconnect re-emits the cached (Running, locked) truth"); + + let env = tokio::time::timeout( + std::time::Duration::from_secs(2), + futures_util::StreamExt::next(&mut events), + ) + .await + .expect("not hang") + .expect("a Snapshot envelope"); + assert!( + matches!(env.event, SessionEvent::Snapshot { turn_gen: 4, .. }), + "reconnect emits a Snapshot event (the first producer), got {:?}", + env.event + ); + } + + // ====================================================================== + // Combinatorial-timing coverage (2026-06-17 audit gaps #1/#4/#5). The + // existing flush tests only ever hold ONE Held entry; these pin the + // MULTI-pending FIFO order, the dispatch-failure rollback arm, and the + // T7b-vs-T7c cancel semantics over a populated queue. + // ====================================================================== + + /// Gap #1 (audit): MULTIPLE pending stacked → FIFO drain by `enqueue_ordinal`, + /// lowest-first, ONE head per rising edge. Every prior flush test enqueues a + /// single Held; the `enqueue_ordinal` ORDERING across 3 entries (and the + /// "exactly one head per edge, not drain-all" rule, §12.6.10) was untested. + #[tokio::test] + async fn multiple_pending_drain_fifo_one_head_per_rising_edge() { + let (convo, orch, _backend) = facade("s1").await; + // Gate closed → all three sends stay Held (no send-time flush). + orch.seed_latest_for_test(snap("s1", false, false)); + let convo = Arc::new(convo); + convo.send(vec![ContentBlock::Text("a".into())]).await.expect("ok"); + convo.send(vec![ContentBlock::Text("b".into())]).await.expect("ok"); + convo.send(vec![ContentBlock::Text("c".into())]).await.expect("ok"); + let q = convo.pending().await; + assert_eq!(q.len(), 3, "three Held entries stacked"); + assert!(q.iter().all(|m| m.status == MsgStatus::Held), "all Held, got {q:?}"); + assert_eq!( + q.iter().map(|m| m.enqueue_ordinal).collect::>(), + vec![1, 2, 3], + "monotonic ordinals minted in send order" + ); + + // A rising edge dispatches EXACTLY ONE head = the lowest-ordinal Held (m-1), + // NOT a drain-all. We open the gate, fire one try_flush_head, and assert only + // m-1 went Sent while m-2/m-3 stay Held. + orch.seed_latest_for_test(snap("s1", true, false)); + let r1 = convo.try_flush_on_edge_for_test().await; + assert!(matches!(r1, Some(Ok(_))), "first edge dispatches a head, got {r1:?}"); + let q = convo.pending().await; + let by_id = |id: &str| q.iter().find(|m| m.client_msg_id == id).unwrap().status; + assert_eq!(by_id("m-1"), MsgStatus::Sent, "lowest ordinal dispatched first"); + assert_eq!(by_id("m-2"), MsgStatus::Held, "m-2 waits for the next edge"); + assert_eq!(by_id("m-3"), MsgStatus::Held, "m-3 waits for the next edge"); + + // Next edge → m-2 (the new lowest-ordinal Held), still leaving m-3 Held. + let r2 = convo.try_flush_on_edge_for_test().await; + assert!(matches!(r2, Some(Ok(_))), "second edge dispatches the next head"); + let q = convo.pending().await; + let by_id = |id: &str| q.iter().find(|m| m.client_msg_id == id).unwrap().status; + assert_eq!(by_id("m-2"), MsgStatus::Sent, "m-2 dispatched second (FIFO)"); + assert_eq!(by_id("m-3"), MsgStatus::Held, "m-3 still waiting"); + + // Third edge → m-3, queue fully drained to Sent. + let r3 = convo.try_flush_on_edge_for_test().await; + assert!(matches!(r3, Some(Ok(_))), "third edge dispatches the last head"); + assert!( + convo.pending().await.iter().all(|m| m.status == MsgStatus::Sent), + "all three dispatched in FIFO ordinal order" + ); + // Nothing left to flush. + assert!( + convo.try_flush_head().await.is_none(), + "no Held head remains after the queue is drained" + ); + } + + /// A `SessionBackend` whose `dispatch(Send)` ALWAYS fails — used to drive the + /// PC-ERROR-7 rollback arm (`try_flush_head` marks the head `Error`). Other + /// commands succeed as NoTurn; `events()` is empty (we only test the send path). + struct FailingSendBackend; + #[async_trait::async_trait] + impl SessionBackend for FailingSendBackend { + async fn dispatch(&self, c: crate::backend::types::Command) -> Result { + match c { + crate::backend::types::Command::Send { .. } => Err(BackendError::Transport("stdin dead".into())), + _ => Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: 0, + }), + } + } + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + use futures_util::StreamExt as _; + futures_util::stream::empty().boxed() + } + fn capabilities(&self) -> Capabilities { + Capabilities::default() + } + } + + /// Gap #4 (audit): a send whose backend dispatch FAILS marks the pending entry + /// `Error` (not stuck `Held` forever) and surfaces the Err to the caller, so the + /// UI bubble rolls back. `MsgStatus::Error` is the production failure arm in + /// `try_flush_head` (conversation_session.rs:220) and had ZERO test coverage. + #[tokio::test] + async fn send_dispatch_failure_marks_pending_error_and_returns_err() { + let backend: Arc = Arc::new(FailingSendBackend); + let orch = Arc::new(Orchestrator::new(256)); + // No seeded snapshot → initial gate is open (pre-first-transition Idle), so + // send() flushes the head immediately and we observe the failure arm now. + let convo = ConversationSession::new("s-fail", orch, backend); + + let r = convo.send(vec![ContentBlock::Text("doomed".into())]).await; + assert!( + r.is_err(), + "a failed dispatch must surface the Err to the caller (bubble rollback), got {r:?}" + ); + + // The pending entry is marked Error — NOT left Held (which would hang the + // bubble) and NOT Sent (it never reached the backend). + let q = convo.pending().await; + assert_eq!(q.len(), 1, "the entry stays in the permanent record"); + assert_eq!( + q[0].status, + MsgStatus::Error, + "a dispatch failure marks the entry Error (PC-ERROR-7 rollback arm), got {:?}", + q[0].status + ); + + // And it is terminal for the flush engine: no Held head, nothing re-dispatches. + assert!( + convo.try_flush_head().await.is_none(), + "an Error entry is NOT a Held head — the flush engine never retries it" + ); + } + + /// Gap #5 (audit): T7b (Esc = `Cancel{Turn}`) vs T7c (`cancel_all_outstanding` + /// = session teardown) have OPPOSITE semantics for a Held queue, but no test + /// exercised them over a populated queue. T7b leaves Held in place (it + /// auto-flushes on the next rising edge); ONLY T7c clears the whole queue. + #[tokio::test] + async fn esc_cancel_turn_leaves_held_but_teardown_clears_it() { + let (convo, orch, _backend) = facade("s1").await; + // A turn is running (m-1 Sent) and the user typed m-2 while blocked → Held. + orch.seed_latest_for_test(snap("s1", true, false)); + convo + .send(vec![ContentBlock::Text("running".into())]) + .await + .expect("ok"); // → Sent + orch.seed_latest_for_test(snap("s1", false, false)); // gate now closed (turn in flight) + convo + .send(vec![ContentBlock::Text("typed-while-busy".into())]) + .await + .expect("ok"); // → Held + let q = convo.pending().await; + assert_eq!( + q.iter().find(|m| m.client_msg_id == "m-1").unwrap().status, + MsgStatus::Sent + ); + assert_eq!( + q.iter().find(|m| m.client_msg_id == "m-2").unwrap().status, + MsgStatus::Held + ); + + // T7b: Esc cancels the TURN (a wire Cancel{Turn}). It must NOT touch the + // local queue — m-2 stays Held to auto-flush as the next turn. + convo.cancel(CancelTarget::Turn).await.expect("Cancel{Turn} dispatched"); + let q = convo.pending().await; + assert_eq!( + q.iter().find(|m| m.client_msg_id == "m-2").unwrap().status, + MsgStatus::Held, + "T7b (Esc) leaves the Held message in place to auto-flush — it must NOT be dropped" + ); + // Proof of "auto-flush": once the gate reopens (turn ended), the Held head + // dispatches with no user action — a real post-cancel rising edge. + orch.seed_latest_for_test(snap("s1", true, false)); + let r = convo.try_flush_on_edge_for_test().await; + assert!( + matches!(r, Some(Ok(_))), + "Held m-2 auto-flushes on the post-cancel rising edge" + ); + assert_eq!( + convo + .pending() + .await + .iter() + .find(|m| m.client_msg_id == "m-2") + .unwrap() + .status, + MsgStatus::Sent, + "T7b: the survived Held message becomes the next turn" + ); + + // T7c: session teardown is the OPPOSITE — it clears the whole queue. Re-arm a + // fresh Held entry, then tear down: every non-terminal entry → Canceled. + orch.seed_latest_for_test(snap("s1", false, false)); + convo.send(vec![ContentBlock::Text("after".into())]).await.expect("ok"); // → Held (m-3) + let n = convo.cancel_all_outstanding().await; + assert!(n >= 1, "teardown cancels the outstanding (Held/Sent) entries, got {n}"); + assert!( + convo + .pending() + .await + .iter() + .all(|m| matches!(m.status, MsgStatus::Canceled | MsgStatus::Accepted)), + "T7c teardown leaves nothing Held/Sent — the whole queue is cleared" + ); + assert!( + convo.try_flush_head().await.is_none(), + "after teardown there is no Held head to auto-flush (opposite of T7b)" + ); + } +} diff --git a/crates/aionui-session/src/backend/mod.rs b/crates/aionui-session/src/backend/mod.rs new file mode 100644 index 000000000..3d580685c --- /dev/null +++ b/crates/aionui-session/src/backend/mod.rs @@ -0,0 +1,238 @@ +//! 007 §C1 the symmetric actor-mailbox backend seam (a-side). Two traits: +//! `BackendConnection` (connection-level: opens per-session handles) and +//! `SessionBackend` (per-session handle: `&self` dispatch + event stream + +//! capabilities). All transport is sealed inside impls; the only thing that +//! crosses the seam is `Command` down and `SessionEnvelope` up. +//! +//! This is the NEW seam (strangler, §8). The legacy `adapter::BackendAdapter` +//! still drives `session::run_turn` until codex (P1) forces the switch; both +//! compile in parallel. + +mod acp_conn; +mod claude_conn; +mod codex_conn; +mod conversation_session; +mod orchestrator; +mod rehydrate; +mod suspend; +mod types; + +pub use acp_conn::{AcpConnection, AcpSessionBackend, acp_capabilities}; +pub use claude_conn::{ClaudeConnection, ClaudeSessionBackend}; +pub use codex_conn::{CodexConnection, CodexSessionBackend, codex_capabilities}; +pub use conversation_session::{ConversationSession, MsgStatus, PendingMessage}; +pub use orchestrator::Orchestrator; +pub use rehydrate::rehydrate; +// `suspend::{SuspendController, ProcHandle, spawn_idle_timer}` is the F-4 idle +// core; it is consumed intra-crate by the claude/codex/acp backend impls (each +// embeds a `SuspendController`), so it is not re-exported from the crate root. +pub use types::{ + Admission, BackendError, CancelTarget, Command, CommandMeta, CommandReceipt, ContentBlock, PendingPermissionView, + PermissionDecision, QuestionAnswer, SessionEnvelope, SessionInfoKind, SessionSpec, StateSnapshot, Tier2Checkpoint, + TransitionReason, command_name, +}; + +use crate::capability::Capabilities; +use futures_util::stream::BoxStream; + +/// Shared startup-handshake budget for the bound-thread / bound-session waits +/// (bug-hunt codex-500 + regression-by-rewrite audit). The clean-slate rewrite of +/// the codex/acp handshake wait collapsed the legacy ACP `INIT_TIMEOUT_SECS = 30` +/// (aionui-agent-rest/src/protocol/acp.rs:55, an `oneshot` + `tokio::time::timeout`) +/// into a hardcoded `for _ in 0..40 { sleep(50ms) }` = 2s busy-poll on BOTH backends +/// — a magic constant with no reference to the legacy value. 2s passes on a warm dev +/// box but a cold start / untrusted project slows agent init past it → timeout → 500. +/// Restore the legacy 30s budget (env-overridable via `AIONUI_HANDSHAKE_TIMEOUT_SECS` +/// for genuinely slow environments). Returned as a poll count at the existing 50ms +/// tick so callers keep their loop shape; on exhaustion they return the RETRYABLE +/// `BackendError::HandshakeTimeout` (not a bare Transport→500). +pub(crate) fn handshake_budget() -> std::time::Duration { + let secs = std::env::var("AIONUI_HANDSHAKE_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|s| *s > 0) + .unwrap_or(30); // legacy INIT_TIMEOUT_SECS parity + std::time::Duration::from_secs(secs) +} + +/// Per-session handle (§C1). `&self` (NOT `&mut`) so a connection's many sessions +/// run concurrently — interior mutability is session-scoped behind the impl's +/// own locks/channels; the only shared serialized resource is the transport's +/// frame writer (a microsecond byte-frame lock, not a per-turn lock). +#[async_trait::async_trait] +pub trait SessionBackend: Send + Sync { + /// Dispatch a command. FAST: returns a receipt the instant the adapter has + /// accepted+queued it on the wire — does NOT block on turn completion (the + /// turn flows up `events()`). Capability-gated: an unsupported command + /// returns `BackendError::CommandNotSupported`. + async fn dispatch(&self, command: Command) -> Result; + + /// The upward event stream, wrapped in `SessionEnvelope{session_id, turn_gen, + /// event}`. A process crash flows OUT as `Detached{exit}` (no `wait_for_exit` + /// on the trait). The orchestrator unwraps `.event` before `step()`. + fn events(&self) -> BoxStream<'static, SessionEnvelope>; + + /// Read-once capability snapshot (gates UI affordances + dispatch). Immutable + /// within a session; reconnect mints a NEW handle with a fresh snapshot. + fn capabilities(&self) -> Capabilities; + + /// Read-only snapshot of currently-open (unanswered) permission requests, for + /// REST recovery (`GET /confirmations`) of a reloaded `waiting_confirmation` + /// conversation — the live process is still alive, but the permission-card + /// detail (transient, never persisted) was lost on the frontend reload. Reads + /// the adapter's transient pending registry; NOT reducer/FSM/persisted. + /// + /// Default empty: only backends with no unanswered-permission registry and every + /// test double need no change. claude and codex both override it (claude from its + /// `can_use_tool` pending map; codex from its `*/requestApproval` + elicitation + /// registry). NOTE: the ACP `SessionBackend` (`acp_conn`) DOES keep such a + /// registry (`pending_perm_options`, populated on `session/request_permission`) + /// but does not yet override this — a latent recovery gap for the day the ACP + /// SessionBackend is routed into `AgentInstance::Session` (today only claude/codex + /// are; opencode/gemini/hermes still use the legacy `AgentInstance::Acp` path, + /// which recovers via its own `permission_router`). + fn pending_permission_requests(&self) -> Vec { + Vec::new() + } +} + +/// Connection-level factory (§C1): holds the transport singleton and mints many +/// per-session `SessionBackend` actors (one connection multiplexes many sessions). +#[async_trait::async_trait] +pub trait BackendConnection: Send + Sync { + /// Open (Fresh) or re-attach (Resume) a session. On `Resume`, the adapter + /// uses `backend_session_id` to re-attach transport (or mints a fresh backend + /// session if None/dead), then re-registers the SAME logical `session_id`. + async fn open_session( + &self, + spec: SessionSpec, + config: SessionConfig, + ) -> Result, BackendError>; + + /// Close + release a session's transport binding. + async fn close_session(&self, session_id: &str) -> Result<(), BackendError>; + + /// Connection-level capabilities (the same shape a freshly-opened session + /// reports; lets the caller gate UI before opening). + fn capabilities(&self) -> Capabilities; +} + +/// A neutral, SDK-free MCP server spec (Wave 0c). Carries one resolved MCP server +/// into `open_session` so each backend serializes it into ITS OWN wire shape (acp +/// → `session/new`|`session/load` `mcpServers[]`; codex → `thread/start`; aionrs → +/// its `McpServerConfig` map). Deliberately crate-local — `aionui-session` stays +/// self-contained (no `aionui-api-types`/ACP-SDK dep, §Cargo.toml). The app +/// boundary (which owns the catalog + async command resolution) converts its own +/// `SessionMcpServer` into this AFTER resolving stdio launch commands, so the spec +/// that arrives here is final (a backend never re-resolves a launch command). +#[derive(Debug, Clone, PartialEq)] +pub struct McpServerSpec { + pub name: String, + pub transport: McpTransport, +} + +/// MCP transport variants (SDK-free mirror of the neutral session-snapshot shape). +#[derive(Debug, Clone, PartialEq)] +pub enum McpTransport { + /// stdio: a resolved launch command + args + env (already runtime-resolved by + /// the app boundary — NOT re-resolved here). + Stdio { + command: String, + args: Vec, + env: Vec<(String, String)>, + }, + /// HTTP (or streamable-HTTP) transport: url + headers. + Http { + url: String, + headers: Vec<(String, String)>, + }, + /// SSE transport: url + headers. + Sse { + url: String, + headers: Vec<(String, String)>, + }, +} + +/// The session-INITIALIZATION surface (Wave 0c) the legacy ai-agent factory +/// provided, carried neutrally so each backend's `open_session` serializes it into +/// its own wire shape. `Default` = empty = byte-identical to the pre-0c handshake, +/// so adding this to `SessionConfig` is a zero-behavior-change additive widening +/// until a caller populates it. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SessionInit { + /// User/team/guide MCP servers, already resolved + flattened by the app + /// boundary. Empty = no injection (the pre-0c default). + pub mcp_servers: Vec, + /// Skill ids/names to surface to the agent on the first turn (acp/aionrs + /// deliver these via the first prompt, not a `session/new` param). + pub skills: Vec, + /// Composed system prompt / preset context (the `compose_preset_context` + /// output + aionrs `preset_rules`-merged prompt). Delivered first-message. + pub preset_context: Option, + /// Opaque persisted session snapshot for resume (aionrs `SessionManager` JSON + /// blob). Kept as `serde_json::Value` so `aionui-session` need not depend on + /// the `aion-agent` `Session` type; the aionrs backend deserializes it. + pub session_snapshot: Option, + /// Explicit resume flag. acp/codex resume via `SessionSpec::Resume`; this is the + /// aionrs equivalent + the single source of truth for "sanitize on resume". + pub resume: bool, +} + +/// Orchestration-side (NOT b-side) session config carried into `open_session`. +/// Holds the runtime knobs the adapter needs but the reducer never sees. +/// +/// `Eq` is intentionally NOT derived: `SessionInit.session_snapshot` is a +/// `serde_json::Value` (which is `PartialEq` but not `Eq`). No consumer needs +/// `SessionConfig: Eq` (verified — no `HashSet`/`BTreeSet`/`==` use). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SessionConfig { + pub cwd: Option, + pub model: Option, + pub mode: Option, + pub extra_args: Vec, + /// F-4 idle self-suspend TTL (ms). `None` (default) = never suspend: the + /// backend keeps its process resident for life, byte-identical to the pre-F-4 + /// path. `Some(ttl)` = a backend that supports graceful resume (claude + /// `--resume`, codex `thread/resume`, acp `session/load`) closes its process + /// after `ttl` ms idle and re-spawns it (resuming the same backend session) on + /// the next dispatch. Opt-in so the claude parse zero-diff acceptance is + /// unaffected unless explicitly enabled. See `backend/suspend.rs`. + pub idle_ttl_ms: Option, + /// Wave 0c: the session-initialization surface (MCP / skills / preset / snapshot + /// / resume). `Default` (empty) = pre-0c handshake byte-identical, so existing + /// callers that `..Default::default()` are unaffected until they populate it. + pub init: SessionInit, + /// #103: extra environment variables injected into the spawned backend + /// process's `CommandSpec.env`. The orchestration layer (app registry) fills + /// this — e.g. the claude direct-CLI path injects the cc-switch provider env + /// (`ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN`) here for backend == "claude", + /// mirroring the legacy ACP path that read it from cc-switch at spawn. The + /// session/backend layer stays cc-switch-agnostic: it only forwards this + /// neutral data into the spawn. `Default` (empty) = inherit the parent env + /// only (byte-identical to the pre-#103 spawn). Carried into the F-4 wake + /// recipe so a resume-respawn re-applies the same env (R16 continuity). + pub spawn_env: Vec, + /// G1-A: the codex sandbox policy injected into `thread/start` (the native + /// codex app-server `sandbox` field; codex_conn serializes it data-driven + /// instead of the hardcoded `"workspace-write"`). `None` (default) ⇒ + /// `workspace-write`, byte-identical to the pre-G1-A handshake. The + /// orchestration layer (app registry) resolves the agent's requested mode + /// (e.g. a yolo agent → `"danger-full-access"`) and fills this — mirroring the + /// #103 spawn_env seam: the session/backend layer stays policy-agnostic and + /// only forwards the neutral string. Backends other than codex ignore it. + /// Carried into the codex F-4 wake recipe so a resume re-applies the same + /// sandbox (R16 continuity). + pub sandbox_mode: Option, + /// The codex approval policy injected into `thread/start` (the native codex + /// app-server `approvalPolicy` field; codex_conn serializes it data-driven + /// instead of the hardcoded `"on-request"`). `None` (default) ⇒ `"on-request"`, + /// byte-identical to the pre-data-driven handshake. The orchestration layer + /// (app registry) resolves the agent's requested mode (e.g. a yolo / full-access + /// agent → `"never"`, never prompt) and fills this — the exact sibling of + /// `sandbox_mode`: the session/backend layer stays policy-agnostic and only + /// forwards the neutral string. Valid codex values: `untrusted` / `on-failure` + /// / `on-request` / `never`. Carried into the codex F-4 wake recipe (it rides + /// `CodexWakeRecipe.config`) so a resume re-applies the same policy (R16 + /// continuity). Backends other than codex ignore it. + pub approval_policy: Option, +} diff --git a/crates/aionui-session/src/backend/orchestrator.rs b/crates/aionui-session/src/backend/orchestrator.rs new file mode 100644 index 000000000..b08814bc2 --- /dev/null +++ b/crates/aionui-session/src/backend/orchestrator.rs @@ -0,0 +1,2915 @@ +//! 007 §3 / §9.12: the orchestrator fold loop — the ONE place `step()` is +//! called (preserving I9). It drives a `SessionBackend`'s `events()` stream: +//! routes each `SessionEnvelope` by `session_id` to a per-session FSM, folds it +//! through the monomorphic reducer, and broadcasts BOTH the raw envelope (to +//! UI/transcript/persistence) AND — on every state change — a FULL +//! `StateSnapshot` (Addendum 8: full push, not incremental; a late/reconnecting +//! subscriber gets complete truth in its first message). +//! +//! Two backend-agnostic details handled here (NOT in the reducer): +//! - epoch restamp (§5.4): the adapter emits `TurnResult{epoch:0}` (it has no +//! turn context); the orchestrator stamps the live `env.turn_gen` before +//! `step()` so the reducer's cross-turn stale-result guard works on a +//! persistent process. +//! - I14 subagent prune: terminal subagents are pruned from `Running.subagents` +//! at a turn boundary (orchestrator layer — the reducer only upserts). + +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::{Mutex, broadcast, mpsc}; + +use super::SessionBackend; +use super::types::{SessionEnvelope, StateSnapshot, TransitionReason}; +use crate::event::{SessionEvent, SubagentStatus}; +use crate::reducer::{Transition, step}; +use crate::state::{SessionState, can_send_message, has_foreground_activity}; +use futures_util::stream::{BoxStream, StreamExt}; + +/// Broadcast fan-out for one orchestrated backend. Conversation subscribers +/// resubscribe to either stream; everything is demuxed by `session_id`. +#[derive(Clone)] +pub struct Orchestrator { + /// Raw envelopes (transcript / streaming UI / persistence). + event_tx: broadcast::Sender, + /// Full state snapshots (the unlock signal lives on `can_send`). + state_tx: broadcast::Sender, + /// Orchestration-LOWERED events injected by `send`/`cancel` (TurnStarted, + /// Cancel) — merged into the fold loop alongside backend events. This is how + /// `Command::Send` becomes `TurnStarted{epoch}` (§3 lower): the orchestrator, + /// NOT the backend, produces it (TurnStarted is orchestration-lowered, never + /// backend-produced — I9). + lowered_tx: mpsc::UnboundedSender, + lowered_rx: Arc>>>, + /// Per-session LATEST state snapshot (the G1 fix). Updated on every push. + /// A state subscriber that LAGS (broadcast ring overwrote its un-consumed + /// snapshots, possibly including the single unlock) recovers the current + /// truth from here on `Lagged` — so the unlock can never be permanently lost + /// to lag. Also seeds a late/reconnect subscriber's first snapshot (G8). + /// Mirrors Addendum 8's "full snapshot is always re-derivable" guarantee. + latest: Arc>>, + /// Per-session STICKY last-terminal `(turn_gen, TransitionReason)` — written + /// only when a turn folds to a terminal phase (Idle/Error), and NOT overwritten + /// by the next turn's activity-edge pushes (unlike `latest`, whose `last_reason` + /// is clobbered the moment the next turn starts). This is the lag-recovering + /// terminal oracle a stall-intolerant consumer (the Route B team reactor) reads + /// after a `Lagged` drop on the domain ring to learn "did the last turn complete + /// or was it cancelled?" without trusting it caught the live `TurnCompleted`. + /// Holds `TransitionReason` (the session-native outcome: `Completed`/`Cancelled` + /// /`Errored`); the conversation layer maps it to its own `TurnOutcomeTag` + /// (one-directional dep — `aionui-session` never names the upper-layer tag). + last_terminal: Arc>>, +} + +impl Orchestrator { + /// Create an orchestrator with bounded broadcast buffers. `cap` sizes both + /// fan-out rings (a slow subscriber that lags surfaces `Lagged`/`Closed` on + /// its own receiver, never blocking the fold loop). + pub fn new(cap: usize) -> Self { + let (event_tx, _) = broadcast::channel(cap); + let (state_tx, _) = broadcast::channel(cap); + let (lowered_tx, lowered_rx) = mpsc::unbounded_channel(); + Self { + event_tx, + state_tx, + lowered_tx, + lowered_rx: Arc::new(Mutex::new(Some(lowered_rx))), + latest: Arc::new(std::sync::Mutex::new(HashMap::new())), + last_terminal: Arc::new(std::sync::Mutex::new(HashMap::new())), + } + } + + /// THE command entry (§3 lower). Dispatch `Send` to the backend, then lower a + /// `TurnStarted{epoch: receipt.turn_gen}` into the fold loop so the FSM goes + /// Idle→Running. This closes the dispatch↔fold-loop loop: a caller sends, the + /// turn starts, the unlock flips false — all without the backend ever + /// producing TurnStarted (I9). The backend's PromptAccepted / deltas / + /// TurnResult then flow up `events()` and fold normally. + pub async fn send( + &self, + backend: &dyn SessionBackend, + session_id: &str, + content: Vec, + metadata: super::types::CommandMeta, + ) -> Result { + let receipt = backend + .dispatch(super::types::Command::Send { content, metadata }) + .await?; + // Only a Started admission begins a turn; a Queued one will be lowered + // when it is promoted (P0: Started is the path; Queued lowering is a + // later admission-policy slice). + if matches!(receipt.admission, super::types::Admission::Started) { + let _ = self.lowered_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen: receipt.turn_gen, + event: SessionEvent::TurnStarted { + epoch: receipt.turn_gen, + }, + }); + } + Ok(receipt) + } + + /// Lower a user `Cancel` (§004 S14): dispatch to the backend AND fold a + /// `SessionEvent::Cancel` so the FSM folds Running→Idle immediately (the UI + /// unlocks without waiting for the backend's trailing terminal). + pub async fn cancel( + &self, + backend: &dyn SessionBackend, + session_id: &str, + target: super::types::CancelTarget, + ) -> Result { + let receipt = backend.dispatch(super::types::Command::Cancel { target }).await?; + let _ = self.lowered_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen: receipt.turn_gen, + event: SessionEvent::Cancel, + }); + Ok(receipt) + } + + /// Raw `SessionEnvelope` stream, demuxed to one `session_id` (transcript / + /// streaming UI). Every delta flows here in real time (Tier-0 push-not-store). + /// + /// §9.4 backpressure: when a slow subscriber overflows the bounded broadcast + /// buffer, the channel reports `RecvError::Lagged(n)` (n events dropped). We + /// SURFACE that as one `SessionEvent::Lagged{skipped:n}` envelope for this + /// session (reducer-IGNORED, orchestration-lowered) so the consumer KNOWS it + /// missed deltas and can `reconnect()` to catch up — rather than silently + /// continuing with a hole. turn_gen=0 (the lag signal is not tied to a turn). + pub fn subscribe_events(&self, session_id: impl Into) -> BoxStream<'static, SessionEnvelope> { + let me = session_id.into(); + let rx = self.event_tx.subscribe(); + futures_util::stream::unfold((rx, me), |(mut rx, me)| async move { + loop { + match rx.recv().await { + Ok(env) if env.session_id == me => return Some((env, (rx, me))), + Ok(_) => continue, // another session — demux skip + Err(broadcast::error::RecvError::Lagged(n)) => { + // Surface the drop to THIS session's consumer once, then + // keep reading (the consumer reconnects to refill Tier-1). + let env = SessionEnvelope { + session_id: me.clone(), + turn_gen: 0, + event: SessionEvent::Lagged { skipped: n }, + }; + return Some((env, (rx, me))); + } + Err(broadcast::error::RecvError::Closed) => return None, + } + } + }) + .boxed() + } + + /// 009 R4b: synchronous read of this session's LATEST cached snapshot (the + /// same cache `subscribe_state` recovers from on Lagged). The flush engine / + /// `send` try-now path reads `can_send`/`can_queue` from here to decide whether + /// a just-enqueued Held message can dispatch immediately. `None` = no snapshot + /// folded yet (pre-first-transition → treat as initial Idle: can_send=true). + pub fn latest_snapshot(&self, session_id: &str) -> Option { + self.latest.lock().ok().and_then(|m| m.get(session_id).cloned()) + } + + /// Synchronous read of this session's STICKY last-terminal `(turn_gen, reason)` + /// — the lag-recovering terminal oracle (see the `last_terminal` field). Returns + /// `None` before any turn has folded a terminal phase. A consumer that may have + /// missed the live `TurnCompleted` (e.g. on a `Lagged` domain-ring drop) reads + /// this to recover the outcome of the most-recently-settled turn; pairing the + /// `turn_gen` lets it confirm the terminal it recovers is the turn it cares about. + pub fn latest_terminal(&self, session_id: &str) -> Option<(u64, TransitionReason)> { + self.last_terminal.lock().ok().and_then(|m| m.get(session_id).cloned()) + } + + /// Full `StateSnapshot` stream, demuxed to one `session_id`. The conversation + /// reads `snapshot.can_send` for the unlock (NEVER recomputes — §C7). + /// + /// LAG-RECOVERING (G1 fix) + LATE-SEEDING (G8 fix): on `Lagged`, instead of + /// silently skipping (which could drop the single unlock snapshot forever), + /// re-emit this session's LATEST cached snapshot — so the current `can_send` + /// is always eventually delivered, even after the broadcast ring overwrote + /// the live copy. Also seeds the FIRST item from the cache so a late/reconnect + /// subscriber immediately learns the current phase (Addendum 8 reconnect). + pub fn subscribe_state(&self, session_id: impl Into) -> BoxStream<'static, StateSnapshot> { + let me = session_id.into(); + let rx = self.state_tx.subscribe(); + let latest = self.latest.clone(); + // Seed: the current cached snapshot for this session (if any), so a late + // subscriber is not blind until the next transition. + let seed = latest.lock().ok().and_then(|m| m.get(&me).cloned()); + // State = (receiver, latest-cache, my session id, pending seed). The seed + // is delivered on the first poll then dropped to None. + futures_util::stream::unfold((rx, latest, me, seed), |(mut rx, latest, me, seed)| async move { + // First poll: deliver the seed (if any) before touching the channel. + if let Some(s) = seed { + return Some((s, (rx, latest, me, None))); + } + loop { + match rx.recv().await { + Ok(s) if s.session_id == me => return Some((s, (rx, latest, me, None))), + Ok(_) => continue, // another session — demux skip + Err(broadcast::error::RecvError::Lagged(_)) => { + // G1: lag may have dropped our unlock. Recover this + // session's LATEST snapshot from the cache so can_send is + // never permanently lost. + if let Some(s) = latest.lock().ok().and_then(|m| m.get(&me).cloned()) { + return Some((s, (rx, latest, me, None))); + } + // HOLE-G1-A fix: the cache is empty ⟺ this session has had + // NO transition yet ⟺ it is at its initial Idle. Without + // this, an empty-cache Lagged would `continue`-spin forever + // if the session never produces an own-event (e.g. a Queued + // send that never lowered TurnStarted) — a permanent UI lock, + // exactly the bug G1 exists to kill, relocated to the + // pre-first-transition window. Synthesize the truthful + // initial Idle(can_send=true) ONCE so the subscriber is never + // blind. A later real transition supersedes it normally. + let initial = StateSnapshot { + session_id: me.clone(), + state: SessionState::Idle, + can_send: true, + // Idle + no background source → not active (§1.6). + has_activity: false, + // Idle: nothing in flight → cannot queue, cannot cancel. + can_queue: false, + can_cancel: false, + turn_gen: 0, + last_reason: None, + }; + return Some((initial, (rx, latest, me, None))); + } + Err(broadcast::error::RecvError::Closed) => return None, + } + } + }) + .boxed() + } + + /// The derived unlock-bool stream (a `map` over snapshots): `can_send` + /// flipping true is the ONLY unlock signal, decoupled from any blocking + /// return (§C7). Replaces the deleted hardcoded `turn.completed{canSend}`. + pub fn subscribe_unlock(&self, session_id: impl Into) -> BoxStream<'static, bool> { + self.subscribe_state(session_id).map(|s| s.can_send).boxed() + } + + /// GAP-F (§9.3 / C7 reconnect, Addendum 8): re-deliver the current truth to a + /// (re)subscribing consumer. Re-broadcasts this session's cached `latest` + /// StateSnapshot (so a fresh `subscribe_state` immediately re-sees can_send + + /// full FSM) AND emits a `SessionEvent::Snapshot{state_repr, turn_gen}` on the + /// event stream (the first — and only — producer of that variant). This is an + /// ORCHESTRATION SIGNAL, NOT a `Command` (it never touches a backend or the + /// FSM). P0 scope = re-emit cached truth; Tier-1 transcript backfill + + /// live-tail-resume are the deferred P2 conversation-side slice. No-op if the + /// session has no cached snapshot yet (nothing has happened to re-deliver). + pub async fn reconnect(&self, session_id: &str) { + let snap = self + .latest + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(session_id) + .cloned(); + if let Some(snap) = snap { + let _ = self.event_tx.send(SessionEnvelope { + session_id: session_id.to_string(), + turn_gen: snap.turn_gen, + event: SessionEvent::Snapshot { + state_repr: format!("{:?}", snap.state), + turn_gen: snap.turn_gen, + }, + }); + let _ = self.state_tx.send(snap); + } + } + + /// Test-only: a handle to the state broadcast sender, so a test can push a + /// synthetic `StateSnapshot` (e.g. to verify cross-session demux without + /// driving a full turn). Gated so production callers can't bypass `fold_one`. + #[cfg(any(test, feature = "test-support"))] + pub fn state_tx_for_test(&self) -> broadcast::Sender { + self.state_tx.clone() + } + + /// Test-only: a handle to the EVENT broadcast sender, so a test can flood the + /// ring to force `RecvError::Lagged` and verify `subscribe_events` surfaces a + /// `SessionEvent::Lagged`. Gated so production callers can't bypass the backend. + #[cfg(any(test, feature = "test-support"))] + pub fn event_tx_for_test(&self) -> broadcast::Sender { + self.event_tx.clone() + } + + /// Test-only: seed the per-session `latest` cache directly (production fills it + /// via `fold_one`), so a test can exercise `reconnect`'s re-emit without driving + /// a full turn. + #[cfg(any(test, feature = "test-support"))] + pub fn seed_latest_for_test(&self, snap: StateSnapshot) { + self.latest + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(snap.session_id.clone(), snap); + } + + /// Test-only: seed the per-session sticky `last_terminal` cache directly + /// (production fills it in `fold_one` at a terminal fold), so a test can exercise + /// the lag-recovering `latest_terminal` read without driving a full turn. + #[cfg(any(test, feature = "test-support"))] + pub fn seed_last_terminal_for_test(&self, session_id: impl Into, turn_gen: u64, reason: TransitionReason) { + self.last_terminal + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(session_id.into(), (turn_gen, reason)); + } + + /// Drive a backend through the reducer until its event stream ends (backend + /// dropped / process exited). The ONLY `step()` call site (I9). MERGES two + /// inputs into one fold lane: (a) the backend's `events()` (backend-produced) + /// and (b) the orchestration-LOWERED events from `send`/`cancel` (TurnStarted + /// /Cancel). The lowered TurnStarted is what moves the FSM Idle→Running — the + /// backend never produces it. Runs to completion; spawn it on a task. + pub async fn run(&self, backend: &dyn SessionBackend) { + // 009 R2: the backend's proactive-input capability is fixed for the + // session; snapshot it once so every fold can derive `can_queue` without + // re-querying. (claude=true via stdin FIFO; codex/acp/aionrs=false.) + let accepts_proactive_input = backend.capabilities().accepts_proactive_input; + let mut events = backend.events(); + // Take the lowered-event receiver (single consumer = this run loop). + let mut lowered = self + .lowered_rx + .lock() + .await + .take() + .expect("Orchestrator::run called more than once"); + // One FSM lane per session (a connection may multiplex many — §4). + let mut fsms: HashMap = HashMap::new(); + // 009 R6: the BACKGROUND-plane workflow roster, one map per session. Lives + // alongside `fsms` (single-owner = this run loop). Unlike `Running.subagents` + // (which the FSM drops when the turn leaves Running), a roster entry OUTLIVES + // its turn — so `has_activity`'s background half stays true while a Workflow + // runs past the turn that spawned it (semantic-②). + let mut rosters: HashMap> = HashMap::new(); + + loop { + // Bias toward lowered events (a TurnStarted lowered by `send` must be + // folded BEFORE the backend's resulting deltas) — `tokio::select!` with + // the lowered branch first + `biased` ordering. + let env = tokio::select! { + biased; + Some(low) = lowered.recv() => low, + next = events.next() => match next { + Some(env) => env, + None => break, // backend stream ended + }, + }; + self.fold_one(&mut fsms, &mut rosters, env, accepts_proactive_input); + } + + // Drain any remaining lowered events (e.g. a Cancel issued as the stream + // ended) so a late unlock still fires. + while let Ok(env) = lowered.try_recv() { + self.fold_one(&mut fsms, &mut rosters, env, accepts_proactive_input); + } + } + + /// Fold one envelope (backend-produced OR lowered) through the reducer + + /// broadcast. Extracted so `run`'s merged select stays readable. + fn fold_one( + &self, + fsms: &mut HashMap, + rosters: &mut HashMap>, + mut env: SessionEnvelope, + accepts_proactive_input: bool, + ) { + // (1) epoch restamp (§5.4): a persistent-process adapter emits + // TurnResult.epoch=0 (no turn context); stamp the live turn_gen so the + // reducer's cross-turn stale-result guard works. Other events unchanged. + restamp_epoch(&mut env); + + // (1b) OBSERVABILITY (turn-lifecycle boundaries only): the fold loop is the + // single chokepoint every SessionEvent passes through, so log the turn-shape + // markers here at info — without this the send→fold→terminal path is a black + // hole (a turn that never produces output leaves no trace of WHERE it stalled). + // Only the boundary/terminal variants are logged (NOT the high-frequency + // deltas), and only event SHAPE (variant + turn_gen) — never prompt/output text + // (AGENTS.md: no sensitive payloads in production logs). + match &env.event { + SessionEvent::TurnStarted { epoch } => tracing::info!( + conversation_id = %env.session_id, + turn_gen = env.turn_gen, + epoch, + "fold: TurnStarted" + ), + SessionEvent::TurnResult { + is_error, + api_error_status, + .. + } => tracing::info!( + conversation_id = %env.session_id, + turn_gen = env.turn_gen, + is_error, + api_error_status, + "fold: TurnResult (terminal)" + ), + SessionEvent::Detached { exit, .. } => tracing::info!( + conversation_id = %env.session_id, + turn_gen = env.turn_gen, + exit_code = exit.as_ref().and_then(|e| e.code), + "fold: Detached (backend gone)" + ), + SessionEvent::Cancel => tracing::info!( + conversation_id = %env.session_id, + turn_gen = env.turn_gen, + "fold: Cancel" + ), + SessionEvent::PromptAccepted { .. } => tracing::info!( + conversation_id = %env.session_id, + turn_gen = env.turn_gen, + "fold: PromptAccepted" + ), + _ => {} + } + + // (2) raw envelope to UI/transcript/persistence (every event, incl. every + // delta — Tier-0 push-not-store, §7). Lowered TurnStarted/Cancel also + // flow here so a transcript sees the turn boundary. + let _ = self.event_tx.send(env.clone()); + + // (009 R6) BACKGROUND plane: mirror SubagentUpdate into the session's + // workflow_roster (which outlives the turn, unlike the FSM's + // Running.subagents). This is what keeps `has_activity` true while a + // Workflow runs past its spawning turn (semantic-②). Terminal absorption + + // the same FIFO ref keying as the FSM plane. Capture the background-active + // bit BEFORE and AFTER the update so a background-only edge (a workflow + // appears / finishes with no FSM phase change) is detected by the push-gate. + let roster = rosters.entry(env.session_id.clone()).or_default(); + let bg_before = crate::state::background_active(roster); + // 009 R6 cleanup path 3 + crash parity: the process is GONE, so any + // still-running workflow will NEVER deliver its terminal task_notification. + // Clear the roster now, else has_activity stays stuck true forever (§12.7 + // liveness leak). Two structurally-identical "process gone" signals: + // - BackendSuspended: idle-reap suspended the backend (the documented closer). + // - Detached: the process hit a real EOF/exit (crash) mid-workflow → the + // reducer folds this to Error{Crashed}, but the dead process can no longer + // emit the per-ref terminal SubagentUpdate that would terminalize the + // entries. Without clearing, a workflow that was Running at crash time keeps + // background_active() true → has_activity stuck on the Error snapshot. Cancel + // never reaches here as a crash (I10 absorbs the post-cancel Detached, see + // event.rs), so this clear only fires on genuine process loss. + // Other events enrich/upsert as usual. + if matches!( + env.event, + SessionEvent::BackendSuspended | SessionEvent::Detached { .. } + ) { + roster.clear(); + } else { + update_roster(roster, &env.event); + } + let bg_after = crate::state::background_active(roster); + + // (3) fold through the reducer (the one step() call site). + let state = fsms.entry(env.session_id.clone()).or_insert(SessionState::Idle); + let prev = state.clone(); + let (mut next, transitions) = step(state, env.event.clone()); + + // (4) I14 prune: terminal subagents dropped at a turn boundary + // (orchestrator-layer — the reducer only upserts). + prune_terminal_subagents_on_boundary(&prev, &mut next); + + *state = next; + + // (5) push a FULL snapshot (Addendum 8) on a real phase change OR a + // has_activity edge. Record it as this session's LATEST first (G1 fix) so a + // lagging subscriber can recover it even if the broadcast ring overwrites + // the live copy. + // + // §1.6(3) push-gate widening: an FSM phase change always pushes. But + // `has_foreground_activity` also flips on a `SubagentUpdate` that upserts + // into `Running.subagents` WITHOUT crossing an external phase (the reducer + // returns no Transition for a roster-only mutation — reducer.rs §6b b1). The + // canonical case: the main turn blocks on a permission (Running + + // requires_action, has_activity=false) while a previously-spawned subagent is + // still executing (any_subagent_active → has_activity flips true), then that + // subagent finishes (flips back false). Gating ONLY on `transitions` would + // strand the subscriber on the pre-flip value — the spinner would never start + // (or never stop) for a subagent running concurrently with an approval. So we + // also push when `has_foreground_activity` differs across this fold. We + // compare `prev` (the pre-step clone) against the post-prune `*state` (the + // SAME value the snapshot carries), so a boundary prune that drops a terminal + // subagent is reflected in the comparison. We do NOT compare the raw roster + // (status/label churn within the active set is task-side detail, not an + // activity edge) — `has_foreground_activity`'s boolean is the exact projection + // §1.6 specifies. Accumulator-only flips (`saw_substantive_output`, + // `terminal_result_seen`) never reach here: `has_foreground_activity` reads + // only phase + requires_action + the subagent-active set, so a delta flood + // produces NO extra push. + // 009 R6: has_activity = foreground half ∥ BACKGROUND half. Edge = either + // half changing. Foreground compares prev vs post-step FSM; background + // compares the roster bit captured before vs after this event's update. + // A background-only edge (a workflow appears/finishes with no FSM phase + // change) must push, else a reconnecting/lagging subscriber strands on the + // stale activity bit. + let has_activity = has_foreground_activity(state) || bg_after; + let prev_has_activity = has_foreground_activity(&prev) || bg_before; + let activity_changed = prev_has_activity != has_activity; + if !transitions.is_empty() || activity_changed { + // activity-only push: transitions is empty → derive_reason returns None + // (no phase change to attribute), which is correct — last_reason names the + // last PHASE transition, not a roster edge. + let last_reason = derive_reason(transitions.last(), &env.event); + let snap = StateSnapshot { + session_id: env.session_id.clone(), + state: state.clone(), + can_send: can_send_message(state), + // §1.6 / 009 R6: has_activity = the FOREGROUND half (Starting / + // Running working / any FSM subagent active) ∥ the BACKGROUND half + // (`background_active` over this session's workflow_roster, which + // outlives the spawning turn). This realizes semantic-② — a Workflow + // (Task tool) is non-blocking and runs past its turn, so after the + // turn folds Idle the FSM is quiet but the roster keeps has_activity + // true (spinner stays on) until task_notification clears the entry. + // (Was a hardcoded `false` background half — the F6 UNWIRED-bug.) + has_activity, + // 009 R2: capability-gated proactive-queue + FSM-only cancel, + // pre-derived so conversation reads the fields, never recomputes. + can_queue: crate::state::can_queue_message(state, accepts_proactive_input), + can_cancel: crate::state::can_cancel(state), + turn_gen: env.turn_gen, + last_reason, + }; + if let Ok(mut map) = self.latest.lock() { + map.insert(env.session_id.clone(), snap.clone()); + } + // Sticky terminal cache: record (turn_gen, reason) ONLY when this fold + // lands the turn in a terminal phase (Idle/Error) with an attributable + // reason. Last-write-wins on the session key so the G14 cancel-vs-late- + // TurnResult race (a stray TurnResult folding one Idle after a Cancel) + // settles on whichever terminal the reducer emitted last, never a stale + // mix. Activity-only pushes (transitions empty ⇒ last_reason None) and + // non-terminal transitions never touch it, so it survives into the NEXT + // turn as the answer to "what was the last terminal outcome". + if matches!(state, SessionState::Idle | SessionState::Error { .. }) + && let Some(reason) = snap.last_reason.clone() + && let Ok(mut term) = self.last_terminal.lock() + { + term.insert(env.session_id.clone(), (env.turn_gen, reason)); + } + // Resume-anchor self-heal (Wave-5: ownership moved from the legacy + // conversation-side `spawn_claude_transition_subscriber` to the session + // layer). When a turn lands in Error because the persisted resume anchor + // is dead (claude "No conversation found" / `error_during_execution`), + // the binding the conversation persisted as `backend_session_id` is no + // longer usable. Emit `BackendBound { None }` — the documented "backend + // session lost → clear the column" channel — so the facade wipes the stale + // anchor and the NEXT send starts Fresh instead of re-failing the resume. + // The reducer ignores BackendBound (no FSM effect); the facade is the sole + // consumer (clears conversations.backend_session_id). + if let SessionState::Error { reason } = state + && crate::state::is_unrecoverable_resume_error(reason) + { + let _ = self.event_tx.send(SessionEnvelope { + session_id: env.session_id.clone(), + turn_gen: env.turn_gen, + event: SessionEvent::BackendBound { + backend_session_id: None, + }, + }); + } + let _ = self.state_tx.send(snap); + } + } +} + +impl Default for Orchestrator { + fn default() -> Self { + Self::new(1024) + } +} + +/// §5.4: stamp the live `turn_gen` onto a `TurnResult` the adapter left +/// unstamped (epoch 0). Leaves an already-stamped result and all other events +/// untouched. +fn restamp_epoch(env: &mut SessionEnvelope) { + if let SessionEvent::TurnResult { epoch, .. } = &mut env.event + && *epoch == 0 + { + *epoch = env.turn_gen; + } +} + +/// I14: drop terminal subagents from the live roster. NOTE despite the name this +/// runs on EVERY Running fold (not only the turn boundary): a terminal entry is +/// removed the moment its update folds, so a `Completed`/`Errored`/`Shutdown` +/// subagent never lingers in `Running.subagents`. (The `was_running && !still_running` +/// boundary branch is a documented no-op hook for the future "subagents survive +/// across turns" model — leaving Running drops the whole roster anyway.) The +/// `Interrupted` status is deliberately NOT pruned (it is a non-terminal pause that +/// may resume) but also does NOT count as active in `any_subagent_active`, so an +/// interrupted subagent correctly contributes neither a roster entry removal nor a +/// has_activity edge. +fn prune_terminal_subagents_on_boundary(prev: &SessionState, next: &mut SessionState) { + let was_running = matches!(prev, SessionState::Running { .. }); + let still_running = matches!(next, SessionState::Running { .. }); + if was_running && !still_running { + // Leaving Running — the carry would otherwise be discarded entirely on + // the next TurnStarted anyway, so nothing to prune in `next` (it's a + // terminal variant with no subagents field). This hook exists for the + // future "subagents survive across turns" model; today it is a no-op + // documenting WHERE the prune belongs (orchestrator, not reducer). + } + if let SessionState::Running { subagents, .. } = next { + subagents.retain(|s| { + !matches!( + s.status, + SubagentStatus::Completed | SubagentStatus::Errored | SubagentStatus::Shutdown + ) + }); + } +} + +/// 009 R6: mirror a `SubagentUpdate` into the BACKGROUND-plane workflow_roster. +/// Upsert by `ref` (= ref_id) with §11.4 terminal absorption: once an entry's +/// task_status is terminal, a late non-terminal update does NOT resurrect it +/// (mirrors the reducer's foreground-plane rule, so a lagged `progress` after a +/// `Completed` can't re-ignite the background spinner). Only `SubagentUpdate` +/// touches the roster; every other event is a no-op here. Rich fields (model / +/// tokens / loop state) are filled by the claude workflow_progress[] parser in a +/// follow-on (R6b); this step carries ref_id + task_status + label. +fn update_roster(roster: &mut HashMap, event: &SessionEvent) { + use crate::state::{WorkflowAgentState, WorkflowTaskStatus}; + // 009 R6b: rich per-agent detail (claude workflow_progress[]) fills the + // display fields on an existing or new roster entry. Keyed by the same `ref` + // as SubagentUpdate. Never changes task_status (that is SubagentUpdate's job / + // §11.4 absorption); only enriches model/tokens/tools/loop-state. + if let SessionEvent::SubagentDetail { + r#ref, + label, + loop_state, + model, + tokens, + tool_calls, + last_tool_name, + .. + } = event + { + let slot = roster.entry(r#ref.clone()).or_insert_with(|| WorkflowAgentState { + ref_id: r#ref.clone(), + // Detail can arrive before the SubagentUpdate that sets a real status; + // default Running (a detail frame means the agent is active). A later + // SubagentUpdate refines/terminalizes it. + task_status: WorkflowTaskStatus::Running, + // A detail-ONLY entry carries no lifecycle: its `agentId`/label child gets + // no `task_notification` terminal (that terminalizes the container task_id), + // so it must not drive background_active. A subsequent SubagentUpdate on the + // SAME ref (rare — child refs differ from container refs) would set this true. + has_lifecycle: false, + retain: None, + label: None, + state: None, + model: None, + last_tool_name: None, + tokens: None, + tool_calls: None, + }); + if label.is_some() { + slot.label = label.clone(); + } + if loop_state.is_some() { + slot.state = *loop_state; + } + if model.is_some() { + slot.model = model.clone(); + } + if tokens.is_some() { + slot.tokens = *tokens; + } + if tool_calls.is_some() { + slot.tool_calls = *tool_calls; + } + if last_tool_name.is_some() { + slot.last_tool_name = last_tool_name.clone(); + } + return; + } + let SessionEvent::SubagentUpdate { + r#ref, label, status, .. + } = event + else { + return; + }; + // Map the 6-state subagent lifecycle onto the task outcome (orthogonal to the + // claude-only LLM-loop `state`): active states → Running; the rest terminal. + let task_status = match status { + SubagentStatus::PendingInit | SubagentStatus::Running => WorkflowTaskStatus::Running, + SubagentStatus::Completed => WorkflowTaskStatus::Completed, + SubagentStatus::Errored => WorkflowTaskStatus::Failed, + SubagentStatus::Interrupted | SubagentStatus::Shutdown => WorkflowTaskStatus::Stopped, + }; + match roster.get_mut(r#ref) { + // §11.4 absorption: terminal entry + non-terminal update → ignore. + Some(slot) if slot.task_status.is_terminal() && !task_status.is_terminal() => {} + Some(slot) => { + slot.task_status = task_status; + // A SubagentUpdate is the lifecycle signal: this ref has a real + // task_notification terminal path, so it counts toward background activity + // (upgrades a detail-first-created entry from detail-only to lifecycle). + slot.has_lifecycle = true; + if label.is_some() { + slot.label = label.clone(); + } + } + None => { + roster.insert( + r#ref.clone(), + WorkflowAgentState { + ref_id: r#ref.clone(), + task_status, + // Created by a SubagentUpdate → lifecycle-bearing (drives background). + has_lifecycle: true, + retain: None, + label: label.clone(), + state: None, + model: None, + last_tool_name: None, + tokens: None, + tool_calls: None, + }, + ); + } + } +} + +/// Derive the typed `TransitionReason` from the transition's destination + the +/// triggering event (the existing `Transition` carries only from/to/epoch, so +/// the reason is computed here — orchestration-derived, reducer never sees it). +fn derive_reason(t: Option<&Transition>, event: &SessionEvent) -> Option { + let t = t?; + let reason = match &t.to { + SessionState::Running { .. } => match event { + SessionEvent::TurnStarted { .. } => TransitionReason::Started, + SessionEvent::PermissionResolved { .. } => TransitionReason::PermissionResolved, + _ => TransitionReason::Started, + }, + SessionState::Idle => match event { + SessionEvent::Cancel => TransitionReason::Cancelled(crate::event::CancelReason::UserCancel), + SessionEvent::TurnResult { outcome, .. } => match outcome { + crate::event::TurnOutcome::Cancelled { reason } => TransitionReason::Cancelled(*reason), + crate::event::TurnOutcome::Completed { stop_reason } => { + TransitionReason::Completed(stop_reason.clone()) + } + _ => TransitionReason::Completed(crate::event::StopReason::EndTurn), + }, + _ => TransitionReason::Completed(crate::event::StopReason::EndTurn), + }, + SessionState::Error { reason } => TransitionReason::Errored(reason.clone()), + SessionState::Starting => TransitionReason::Started, + }; + // RequiresAction is a sub-condition of Running — if we just crossed INTO it, + // report the permission request reason. + if crate::state::is_requires_action(&t.to) + && !crate::state::is_requires_action(&t.from) + && let SessionEvent::Permission { kind, .. } = event + { + return Some(TransitionReason::PermissionRequested(*kind)); + } + Some(reason) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::types::{Admission, BackendError, Command, CommandReceipt}; + use crate::event::SessionEvent; + + /// A scripted backend that emits a fixed envelope sequence — drives the fold + /// loop deterministically (the live claude/codex backends are tested in their + /// own modules; here we pin the ORCHESTRATOR's fold/snapshot/restamp/prune). + struct ScriptBackend(Vec); + + #[async_trait::async_trait] + impl SessionBackend for ScriptBackend { + async fn dispatch(&self, c: Command) -> Result { + // A real dispatch so the orchestrator's send()/cancel() lowering path + // can be exercised: Send → Started{turn_gen:1}; others → NoTurn. + let admission = match c { + Command::Send { .. } => Admission::Started, + _ => Admission::NoTurn, + }; + Ok(CommandReceipt { + accepted: true, + admission, + turn_gen: 1, + }) + } + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + futures_util::stream::iter(self.0.clone()).boxed() + } + fn capabilities(&self) -> crate::capability::Capabilities { + crate::capability::Capabilities::default() + } + } + + fn env(session_id: &str, turn_gen: u64, event: SessionEvent) -> SessionEnvelope { + SessionEnvelope { + session_id: session_id.into(), + turn_gen, + event, + } + } + + async fn collect_snaps(orch: &Orchestrator, sid: &str, backend: ScriptBackend) -> Vec { + let mut snaps = orch.subscribe_state(sid); + let run = { + let orch = orch.clone(); + tokio::spawn(async move { orch.run(&backend).await }) + }; + let mut out = Vec::new(); + for _ in 0..20 { + match tokio::time::timeout(std::time::Duration::from_secs(2), snaps.next()).await { + Ok(Some(s)) => { + out.push(s.clone()); + if s.can_send && matches!(s.state, SessionState::Idle) { + break; + } + } + _ => break, + } + } + let _ = run.await; + out + } + + /// Like `collect_snaps` but drains EVERY snapshot until the backend stream ends + /// (the script has no terminal → never reaches the Idle break). Used by the + /// subagent/has_activity tests whose sequences stay in Running. + async fn collect_all_snaps(orch: &Orchestrator, sid: &str, backend: ScriptBackend) -> Vec { + let mut snaps = orch.subscribe_state(sid); + let run = { + let orch = orch.clone(); + tokio::spawn(async move { orch.run(&backend).await }) + }; + let mut out = Vec::new(); + // The script backend's event stream is finite; after it drains, run() + // returns and no more snapshots arrive, so the timeout ends collection. + for _ in 0..40 { + match tokio::time::timeout(std::time::Duration::from_millis(300), snaps.next()).await { + Ok(Some(s)) => out.push(s), + _ => break, + } + } + let _ = run.await; + out + } + + /// BASELINE: a PURE-TEXT turn (no subagent/workflow events) folds to Idle with + /// has_activity=false. The roster is empty, so the background half is false and + /// `has_foreground_activity(Idle)` is false. If this ever goes true, the backend + /// is leaking activity into a plain chat turn. + #[tokio::test] + async fn plain_text_turn_idle_snapshot_has_activity_false() { + let orch = Orchestrator::new(256); + let seq = collect_all_snaps( + &orch, + "p", + ScriptBackend(vec![ + env("p", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "p", + 1, + SessionEvent::MessageDelta { + item_id: "m".into(), + text: "hi".into(), + }, + ), + env( + "p", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "hi".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + ]), + ) + .await; + let idle = seq + .iter() + .rev() + .find(|s| matches!(s.state, SessionState::Idle)) + .expect("a final Idle snapshot"); + assert!( + !idle.has_activity, + "a plain-text turn's Idle snapshot must have has_activity=false, got {idle:?}" + ); + } + + /// REGRESSION (stale-has-activity, WS-captured 2026-06-22): a session that ran a + /// workflow earlier leaves the per-child `SubagentDetail` roster entry (keyed by + /// agentId) at task_status=Running FOREVER — `task_notification` only terminalizes + /// the CONTAINER (task_id ref), never the per-agent detail entry, and the per-agent + /// "done" arrives only as a loop_state (which `background_active` does not read). The + /// orchestrator's `rosters` map is run()-scoped (lives across ALL turns of the + /// process), and only Detached/BackendSuspended clear it — so a plain TurnResult→Idle + /// does NOT. Result: EVERY subsequent turn (even a pure chat one) reports + /// has_activity=true → the sidebar spins forever. This pins the bug (currently RED on + /// the second, plain-text turn's Idle snapshot). + #[tokio::test] + async fn workflow_child_detail_leaks_has_activity_into_later_plain_turns() { + use crate::event::SubagentStatus; + let orch = Orchestrator::new(256); + let seq = collect_all_snaps( + &orch, + "w", + ScriptBackend(vec![ + // ── Turn 1: a workflow runs (container + one per-agent child). ── + env("w", 1, SessionEvent::TurnStarted { epoch: 1 }), + // container (task_id ref) starts... + env( + "w", + 1, + SessionEvent::SubagentUpdate { + r#ref: "task-1".into(), + label: Some("Build".into()), + status: SubagentStatus::Running, + parent_ref: Some("toolu-1".into()), + }, + ), + // per-agent child detail (agentId ref) — task_status defaults Running, + // enriched only with loop_state; NOTHING ever terminalizes this ref. Use + // `Progress` (NOT Done): fixture-verified, ~half of workflow_agent refs + // emit only start/progress and never a `done`, so this is the worst case + // — a loop_state-based fix would miss it; the has_lifecycle fix must not. + env( + "w", + 1, + SessionEvent::SubagentDetail { + r#ref: "agent-A".into(), + parent_ref: Some("task-1".into()), + label: Some("run:A".into()), + loop_state: Some(crate::state::WorkflowLoopState::Progress), + model: Some("opus".into()), + tokens: Some(10), + tool_calls: Some(2), + last_tool_name: None, + }, + ), + // container completes (terminalizes task-1, NOT agent-A). + env( + "w", + 1, + SessionEvent::SubagentUpdate { + r#ref: "task-1".into(), + label: None, + status: SubagentStatus::Completed, + parent_ref: Some("toolu-1".into()), + }, + ), + env( + "w", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "done".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + // ── Turn 2: a PLAIN-TEXT turn, no subagent events at all. ── + env("w", 2, SessionEvent::TurnStarted { epoch: 2 }), + env( + "w", + 2, + SessionEvent::MessageDelta { + item_id: "m2".into(), + text: "hello".into(), + }, + ), + env( + "w", + 2, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "hello".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + ]), + ) + .await; + // The LAST Idle snapshot is turn 2 (the plain-text turn). It must NOT report + // has_activity — the workflow child must not haunt a later plain turn. + let last_idle = seq + .iter() + .rev() + .find(|s| matches!(s.state, SessionState::Idle) && s.turn_gen == 2) + .expect("a turn-2 Idle snapshot"); + assert!( + !last_idle.has_activity, + "a plain turn AFTER a finished workflow must have has_activity=false (the per-agent \ + detail entry must not pin the background half forever), got {last_idle:?}" + ); + } + + /// §3/§9.12 end-to-end: a clean turn folds Idle→Running(can_send=false)→ + /// Idle(can_send=true) and the orchestrator pushes a FULL snapshot on each + /// phase change. The unlock is a snapshot field, decoupled from any return. + #[tokio::test] + async fn fold_loop_pushes_full_snapshot_on_phase_change() { + let orch = Orchestrator::new(256); + let seq = collect_snaps( + &orch, + "x", + ScriptBackend(vec![ + env("x", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "x", + 1, + SessionEvent::MessageDelta { + item_id: "m".into(), + text: "hi".into(), + }, + ), + env( + "x", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "hi".into(), + epoch: 0, // unstamped → orchestrator restamps to turn_gen=1 + outcome: crate::event::TurnOutcome::default(), + }, + ), + ]), + ) + .await; + + assert!( + seq.iter() + .any(|s| !s.can_send && matches!(s.state, SessionState::Running { .. })), + "Running snapshot with can_send=false, got {seq:?}" + ); + let idle = seq.iter().find(|s| s.can_send && matches!(s.state, SessionState::Idle)); + assert!(idle.is_some(), "Idle snapshot with can_send=true (unlock), got {seq:?}"); + assert_eq!(idle.unwrap().session_id, "x", "snapshot demuxed by session_id"); + } + + /// Wave-5 resume-anchor self-heal: when a turn lands in Error because the + /// persisted resume anchor is dead (claude "No conversation found"), the + /// orchestrator emits `BackendBound { None }` on the event stream so the + /// conversation facade clears the stale `backend_session_id`. Ownership moved + /// here from the legacy conversation-side transition subscriber. + #[tokio::test] + async fn unrecoverable_resume_error_emits_backend_bound_none() { + let orch = Orchestrator::new(256); + let mut events = orch.subscribe_events("x"); + let run = { + let orch = orch.clone(); + let backend = ScriptBackend(vec![ + env("x", 1, SessionEvent::TurnStarted { epoch: 1 }), + // A bad `--resume` surfaces as an is_error TurnResult while Starting, + // folding to Error{Backend{message:"No conversation found"}}. + env( + "x", + 1, + SessionEvent::TurnResult { + is_error: true, + api_error_status: None, + result_text: "No conversation found".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::Failed, + }, + ), + ]); + tokio::spawn(async move { orch.run(&backend).await }) + }; + + let mut saw_clear = false; + for _ in 0..40 { + match tokio::time::timeout(std::time::Duration::from_millis(300), events.next()).await { + Ok(Some(env)) => { + if matches!( + env.event, + SessionEvent::BackendBound { + backend_session_id: None + } + ) { + saw_clear = true; + break; + } + } + _ => break, + } + } + let _ = run.await; + assert!( + saw_clear, + "unrecoverable resume error must emit BackendBound{{None}} to clear the stale anchor" + ); + } + + /// SS-2 (Route B HB#1): the STICKY `latest_terminal` cache records a settled + /// turn's `(turn_gen, TransitionReason)` and survives into later activity — the + /// lag-recovering oracle a consumer reads after missing the live terminal. + /// `None` before any terminal; a clean turn → `Completed(EndTurn)`; a later turn + /// that ends differently OVERWRITES (sticky-latest, keyed per session). + #[tokio::test] + async fn latest_terminal_is_sticky_and_carries_outcome() { + let orch = Orchestrator::new(256); + assert!(orch.latest_terminal("x").is_none(), "no terminal before any turn"); + + // Two turns through ONE run loop (the orchestrator's spawn-once `lowered_rx` + // forbids a second `run`): turn 1 ends cleanly (EndTurn), turn 2 errors. The + // sticky cache must end pointing at turn 2 (latest-wins, per-session key). + let _ = collect_all_snaps( + &orch, + "x", + ScriptBackend(vec![ + env("x", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "x", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "ok".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), // EndTurn + }, + ), + env("x", 2, SessionEvent::TurnStarted { epoch: 2 }), + env( + "x", + 2, + SessionEvent::TurnResult { + is_error: true, + api_error_status: None, + result_text: "boom".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::Failed, + }, + ), + ]), + ) + .await; + + let (g, r) = orch.latest_terminal("x").expect("terminal recorded"); + assert_eq!( + g, 2, + "sticky cache points at the LATEST settled turn (overwrite, not append)" + ); + assert!( + matches!(r, TransitionReason::Errored(_)), + "turn 2 ended in error → Errored, got {r:?}" + ); + + // A DIFFERENT session never inherits this one's terminal (per-session key). + assert!(orch.latest_terminal("other").is_none(), "terminal is per-session"); + } + + /// 009 R1d / §12.8 CR-15: cross-session FSM isolation. `fold_one` keys one + /// FSM lane per `session_id` (HashMap), so one session crashing must NOT + /// pollute a concurrent healthy session. This holds by construction today, + /// but a refactor back to a single global FSM would silently reintroduce the + /// pollution — so pin it. Interleave a crash on `s_a` (TurnStarted then + /// Detached → Error{Crashed}) with a clean turn on `s_b`, subscribe to `s_b`, + /// and assert `s_b` still reaches a clean Idle unlock (never Error). + #[tokio::test] + async fn fold_isolates_one_session_crash_from_another() { + let orch = Orchestrator::new(256); + let backend = ScriptBackend(vec![ + env("s_a", 1, SessionEvent::TurnStarted { epoch: 1 }), + env("s_b", 1, SessionEvent::TurnStarted { epoch: 1 }), + // s_a's backend process dies mid-turn → Error{Crashed}. + env( + "s_a", + 1, + SessionEvent::Detached { + exit: Some(crate::event::ExitStatusLite { + code: None, + signal: Some(9), + }), + redacted_summary: None, + }, + ), + // s_b finishes cleanly AFTER s_a crashed. + env( + "s_b", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "ok".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + ]); + let seq = collect_snaps(&orch, "s_b", backend).await; + assert!( + seq.iter().all(|s| s.session_id == "s_b"), + "demux: s_b subscriber sees only s_b snapshots, got {seq:?}" + ); + assert!( + !seq.iter().any(|s| matches!(s.state, SessionState::Error { .. })), + "s_a's crash must NOT pollute s_b into Error, got {seq:?}" + ); + assert!( + seq.iter().any(|s| s.can_send && matches!(s.state, SessionState::Idle)), + "s_b reaches a clean Idle unlock despite s_a crashing, got {seq:?}" + ); + } + + /// 🖥️ UI-1 — the derived `subscribe_unlock()` bool stream (the simplest + /// consumer-facing unlock signal: the send button toggling locked→unlocked) had + /// NO direct test. The frontend binds the composer enabled-state to this bool; + /// if it regressed (wrong field projected, or a lag-recovered snapshot leaked a + /// stale can_send) the send button would never re-enable. Drives a clean turn + /// and asserts the bool stream goes false (Running) then true (Idle unlock). + #[tokio::test] + async fn ui1_subscribe_unlock_bool_stream_toggles_false_then_true() { + let orch = Orchestrator::new(256); + let mut unlock = orch.subscribe_unlock("u1"); + let backend = ScriptBackend(vec![ + env("u1", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "u1", + 1, + SessionEvent::MessageDelta { + item_id: "m".into(), + text: "hi".into(), + }, + ), + env( + "u1", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "hi".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + ]); + let run = { + let orch = orch.clone(); + tokio::spawn(async move { orch.run(&backend).await }) + }; + let mut bools = Vec::new(); + for _ in 0..10 { + match tokio::time::timeout(std::time::Duration::from_secs(2), unlock.next()).await { + Ok(Some(b)) => { + bools.push(b); + if b { + break; // saw the unlock + } + } + _ => break, + } + } + let _ = run.await; + assert!( + bools.contains(&false), + "unlock bool stream must emit false while Running (composer locked), got {bools:?}" + ); + assert_eq!( + bools.last(), + Some(&true), + "unlock bool stream must end true on Idle (composer re-enabled), got {bools:?}" + ); + } + + /// 🖥️ UI-2 — `StateSnapshot.last_reason` (the data the frontend renders the + /// turn-end badge from: completed / cancelled / errored) was never asserted on + /// the broadcast snapshot. A clean turn's terminal snapshot must carry + /// `Some(Completed(..))`; a cancel must carry `Some(Cancelled(UserCancel))`. + #[tokio::test] + async fn ui2_state_snapshot_carries_typed_last_reason() { + use crate::backend::types::TransitionReason; + // Clean completion → last_reason = Completed. + let orch = Orchestrator::new(256); + let seq = collect_snaps( + &orch, + "r", + ScriptBackend(vec![ + env("r", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "r", + 1, + SessionEvent::MessageDelta { + item_id: "m".into(), + text: "hi".into(), + }, + ), + env( + "r", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "hi".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + ]), + ) + .await; + let idle = seq + .iter() + .find(|s| s.can_send && matches!(s.state, SessionState::Idle)) + .expect("idle snapshot"); + assert!( + matches!(idle.last_reason, Some(TransitionReason::Completed(_))), + "clean turn's terminal snapshot last_reason = Completed, got {:?}", + idle.last_reason + ); + + // Cancel → last_reason = Cancelled(UserCancel). Drive via send()→cancel() + // (the proven path: send lowers TurnStarted→Running, cancel lowers + // Cancel→Idle), both enqueued before run() drains them. + let orch2 = Orchestrator::new(256); + let mut snaps = orch2.subscribe_state("c"); + // Backend with NO terminal — only the lowered Cancel can settle it. + let backend = ScriptBackend(vec![env( + "c", + 1, + SessionEvent::MessageDelta { + item_id: "m".into(), + text: "thinking".into(), + }, + )]); + orch2 + .send( + &backend, + "c", + vec![crate::backend::types::ContentBlock::Text("go".into())], + crate::backend::types::CommandMeta::default(), + ) + .await + .expect("send"); + orch2 + .cancel(&backend, "c", crate::backend::types::CancelTarget::Turn) + .await + .expect("cancel"); + let run = { + let orch2 = orch2.clone(); + tokio::spawn(async move { orch2.run(&backend).await }) + }; + let mut cancel_reason = None; + for _ in 0..20 { + match tokio::time::timeout(std::time::Duration::from_secs(2), snaps.next()).await { + Ok(Some(s)) => { + if matches!(s.state, SessionState::Idle) && s.can_send { + cancel_reason = s.last_reason.clone(); + break; + } + } + _ => break, + } + } + let _ = run.await; + assert!( + matches!( + cancel_reason, + Some(TransitionReason::Cancelled(crate::event::CancelReason::UserCancel)) + ), + "cancel's terminal snapshot last_reason = Cancelled(UserCancel), got {cancel_reason:?}" + ); + } + + /// 🖥️ UI-3 — the permission closed loop through the ORCHESTRATOR snapshot stream + /// (not just the pure reducer). The frontend gates the composer AND renders the + /// permission card off the broadcast StateSnapshot. A Permission(Tool) must + /// produce a Running snapshot in requires-action with can_send=false + + /// last_reason=PermissionRequested(Tool); the PermissionResolved must return to + /// plain Running. (The turn then completes → Idle unlock.) + #[tokio::test] + async fn ui3_permission_closed_loop_through_snapshot_stream() { + use crate::backend::types::TransitionReason; + use crate::event::PermissionKind; + let orch = Orchestrator::new(256); + let seq = collect_snaps( + &orch, + "p", + ScriptBackend(vec![ + env("p", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "p", + 1, + SessionEvent::Permission { + request_id: "req-1".into(), + kind: PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + ), + env( + "p", + 1, + SessionEvent::PermissionResolved { + request_id: "req-1".into(), + kind: PermissionKind::Tool, + }, + ), + env( + "p", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "done".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + ]), + ) + .await; + // A snapshot in requires-action: Running, can_send=false, reason=PermissionRequested(Tool). + let perm_snap = seq.iter().find(|s| { + matches!(&s.state, SessionState::Running { requires_action, .. } if requires_action.waiting_on_approval > 0) + }); + assert!( + perm_snap.is_some(), + "a requires-action snapshot must surface, got {seq:?}" + ); + let perm_snap = perm_snap.unwrap(); + assert!(!perm_snap.can_send, "composer locked during permission"); + assert!( + matches!( + perm_snap.last_reason, + Some(TransitionReason::PermissionRequested(PermissionKind::Tool)) + ), + "permission snapshot last_reason = PermissionRequested(Tool), got {:?}", + perm_snap.last_reason + ); + // After resolve + result, the final snapshot is the Idle unlock. + let idle = seq.iter().find(|s| s.can_send && matches!(s.state, SessionState::Idle)); + assert!( + idle.is_some(), + "turn completes to Idle unlock after permission resolved, got {seq:?}" + ); + } + + // ===== §1.6(3): subagent-driven has_activity push-gate (the fold_one widening) ===== + // + // A `SubagentUpdate` upserts into `Running.subagents` WITHOUT an FSM phase change + // (reducer §6b b1 → no Transition). When the main turn is parked on a permission + // (Running + requires_action ⇒ has_activity=false), a concurrently-running + // subagent flips `has_foreground_activity` true — and back to false when it + // finishes. The OLD `if !transitions.is_empty()` gate stranded the subscriber on + // the pre-flip value (spinner never starts / never stops). These pin that the + // widened gate (`|| activity_changed`) makes both edges observable on + // subscribe_state, WITHOUT pushing on accumulator/roster-detail noise. + + /// false→true edge: requires_action parks the turn (has_activity=false), then a + /// subagent starts running → a Running snapshot with has_activity=true reaches + /// the subscriber even though no phase changed. Without the widening this frame + /// never arrives (the subscriber stays on the false from the Permission snapshot). + #[tokio::test] + async fn subagent_start_during_requires_action_pushes_has_activity_true() { + use crate::event::{PermissionKind, SubagentStatus}; + let orch = Orchestrator::new(256); + let seq = collect_all_snaps( + &orch, + "sa1", + ScriptBackend(vec![ + env("sa1", 1, SessionEvent::TurnStarted { epoch: 1 }), + // park on a tool permission: Running + requires_action, NO subagent yet. + env( + "sa1", + 1, + SessionEvent::Permission { + request_id: "req-1".into(), + kind: PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + ), + // a subagent starts while the main turn is parked → has_activity flips true. + env( + "sa1", + 1, + SessionEvent::SubagentUpdate { + r#ref: "sub-1".into(), + label: Some("research".into()), + status: SubagentStatus::Running, + parent_ref: None, + }, + ), + ]), + ) + .await; + + // The Permission snapshot: requires_action, has_activity=false (no subagent yet). + let parked = seq.iter().find(|s| { + matches!(&s.state, SessionState::Running { requires_action, subagents, .. } + if requires_action.waiting_on_approval > 0 && subagents.is_empty()) + }); + assert!( + parked.is_some(), + "a parked (requires_action, no subagent) snapshot, got {seq:?}" + ); + assert!( + !parked.unwrap().has_activity, + "parked-on-approval with no subagent → has_activity=false" + ); + assert!(!parked.unwrap().can_send, "parked → composer locked"); + + // THE fix: a snapshot where the subagent is running → has_activity=true, + // can_send still false (state is still Running+requires_action). This frame + // ONLY exists because the push gate was widened to the has_activity edge. + let spinning = seq.iter().find(|s| { + matches!(&s.state, SessionState::Running { subagents, .. } + if subagents.iter().any(|sub| matches!(sub.status, SubagentStatus::Running))) + }); + assert!( + spinning.is_some(), + "a subagent-running snapshot must be PUSHED (the §1.6(3) widening), got {seq:?}" + ); + let spinning = spinning.unwrap(); + assert!( + spinning.has_activity, + "subagent running during requires_action → has_activity=true (spinner on)" + ); + assert!( + !spinning.can_send, + "can_send stays false — has_activity is orthogonal to the unlock" + ); + } + + #[tokio::test] + async fn workflow_outlives_turn_keeps_has_activity_with_can_send_true() { + // 009 R6 / F6 / semantic-②: a Workflow spawned in a turn OUTLIVES it. After + // the turn folds Idle (TurnResult), the FSM is quiet (foreground half false) + // but the workflow_roster entry survives → has_activity stays TRUE while + // can_send is TRUE. This is the F6 UNWIRED-bug fix (the background half was a + // hardcoded false). Then the workflow completes → has_activity flips false. + use crate::event::SubagentStatus; + let orch = Orchestrator::new(256); + let seq = collect_all_snaps( + &orch, + "wf1", + ScriptBackend(vec![ + env("wf1", 1, SessionEvent::TurnStarted { epoch: 1 }), + // a background workflow agent starts during the turn. + env( + "wf1", + 1, + SessionEvent::SubagentUpdate { + r#ref: "wkflow-1".into(), + label: Some("build".into()), + status: SubagentStatus::Running, + parent_ref: None, + }, + ), + // the foreground turn finishes — folds Idle. + env( + "wf1", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "started the workflow".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + // later the workflow itself completes (outlived the turn). + env( + "wf1", + 1, + SessionEvent::SubagentUpdate { + r#ref: "wkflow-1".into(), + label: Some("build".into()), + status: SubagentStatus::Completed, + parent_ref: None, + }, + ), + ]), + ) + .await; + + // THE F6 fix: an Idle snapshot (can_send=true) that STILL has_activity=true, + // because the background workflow outlived the turn (semantic-②). + let talking_while_busy = seq + .iter() + .find(|s| s.can_send && matches!(s.state, SessionState::Idle) && s.has_activity); + assert!( + talking_while_busy.is_some(), + "after the turn folds Idle, the outliving workflow keeps has_activity=true \ + with can_send=true (semantic-②), got {seq:?}" + ); + + // The LAST snapshot (workflow completed) → background half false again. + let last = seq.last().expect("at least one snapshot"); + assert!( + !last.has_activity, + "once the workflow completes the background half clears → has_activity=false, got {last:?}" + ); + assert!(last.can_send, "still Idle / can_send after the workflow finishes"); + } + + #[tokio::test] + async fn subagent_detail_fills_rich_roster_fields_keeps_activity() { + // 009 R6b + stale-has-activity fix: a SubagentDetail enriches a per-AGENT + // roster entry (model/tokens/tools/loop-state). On the REAL wire the detail's + // ref is the `agentId` (here "agent-C") while the lifecycle SubagentUpdate's ref + // is the CONTAINER `task_id` (here "wf") — DIFFERENT refs (the detail's agentId + // never receives a SubagentUpdate). So background activity is driven by the + // lifecycle-bearing CONTAINER, not the detail-only child: the running container + // keeps has_activity true past the turn (semantic-②), and the container's + // terminal SubagentUpdate clears it — even though the detail child entry is never + // terminalized (which is exactly why a detail-only entry must NOT drive activity). + use crate::event::SubagentStatus; + let orch = Orchestrator::new(256); + let seq = collect_all_snaps( + &orch, + "wd", + ScriptBackend(vec![ + env("wd", 1, SessionEvent::TurnStarted { epoch: 1 }), + // The lifecycle container (task_id ref) — drives background activity. + env( + "wd", + 1, + SessionEvent::SubagentUpdate { + r#ref: "wf".into(), + label: Some("Build".into()), + status: SubagentStatus::Running, + parent_ref: Some("toolu-1".into()), + }, + ), + // The per-agent child detail (agentId ref, distinct from the container) + // — enrichment only, never terminalized. + env( + "wd", + 1, + SessionEvent::SubagentDetail { + r#ref: "agent-C".into(), + parent_ref: Some("wf".into()), + label: Some("run:C".into()), + loop_state: Some(crate::state::WorkflowLoopState::Progress), + model: Some("opus".into()), + tokens: Some(8576), + tool_calls: Some(0), + last_tool_name: None, + }, + ), + env( + "wd", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "spawned".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + // terminalize the CONTAINER (task_id ref). + env( + "wd", + 1, + SessionEvent::SubagentUpdate { + r#ref: "wf".into(), + label: Some("Build".into()), + status: SubagentStatus::Completed, + parent_ref: Some("toolu-1".into()), + }, + ), + ]), + ) + .await; + // The running CONTAINER kept the background plane active through the Idle fold. + assert!( + seq.iter() + .any(|s| s.can_send && matches!(s.state, SessionState::Idle) && s.has_activity), + "a running workflow CONTAINER keeps has_activity=true past the turn (semantic-②), got {seq:?}" + ); + // The container's terminal SubagentUpdate clears it (the never-terminalized + // detail-only child does NOT keep it pinned — that is the leak this fix closes). + assert!( + !seq.last().unwrap().has_activity, + "terminal container clears background activity despite the still-Running detail child" + ); + } + + #[tokio::test] + async fn backend_suspended_clears_roster_no_stuck_activity() { + // 009 R6 cleanup path 3 / §12.7 liveness: a workflow is running (background + // has_activity=true) when idle-reap suspends the backend. The process is + // gone, so the workflow's terminal task_notification will NEVER arrive — if + // the roster weren't cleared, has_activity would be stuck true forever. + // BackendSuspended clears it → has_activity returns to false. + use crate::event::SubagentStatus; + let orch = Orchestrator::new(256); + let seq = collect_all_snaps( + &orch, + "sus", + ScriptBackend(vec![ + env("sus", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "sus", + 1, + SessionEvent::SubagentUpdate { + r#ref: "w".into(), + label: None, + status: SubagentStatus::Running, + parent_ref: None, + }, + ), + env( + "sus", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "go".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + // background workflow still running here (has_activity true) ... + // ... then idle-reap suspends the backend. + env("sus", 1, SessionEvent::BackendSuspended), + ]), + ) + .await; + // Before suspend: an Idle snapshot with has_activity=true (workflow outlived). + assert!( + seq.iter() + .any(|s| matches!(s.state, SessionState::Idle) && s.has_activity), + "the running workflow kept has_activity=true before suspend, got {seq:?}" + ); + // After suspend: roster cleared → has_activity false (no stuck spinner). + assert!( + !seq.last().unwrap().has_activity, + "BackendSuspended clears the roster → has_activity=false (no liveness leak), got {seq:?}" + ); + } + + #[tokio::test] + async fn crash_mid_workflow_clears_roster_no_stuck_activity() { + // §12.7 liveness, crash parity with BackendSuspended: a workflow is running + // (background has_activity=true) when the process dies mid-turn (Detached → + // Error{Crashed}). The dead process can no longer emit the per-ref terminal + // SubagentUpdate, so without clearing the roster on Detached the entry stays + // Running → background_active() true → has_activity stuck true on the Error + // snapshot forever. Detached must clear the roster exactly like suspend. + use crate::event::SubagentStatus; + let orch = Orchestrator::new(256); + let seq = collect_all_snaps( + &orch, + "crash", + ScriptBackend(vec![ + env("crash", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "crash", + 1, + SessionEvent::SubagentUpdate { + r#ref: "w".into(), + label: None, + status: SubagentStatus::Running, + parent_ref: None, + }, + ), + // The background workflow is running (has_activity true) ... + // ... then the process crashes mid-turn (real EOF/exit). + env( + "crash", + 1, + SessionEvent::Detached { + exit: None, + redacted_summary: None, + }, + ), + ]), + ) + .await; + // Before crash: a snapshot with has_activity=true (workflow running). + assert!( + seq.iter().any(|s| s.has_activity), + "the running workflow kept has_activity=true before the crash, got {seq:?}" + ); + // After crash: the turn is Error AND the roster was cleared → has_activity + // false (no stuck spinner on a dead session). + let last = seq.last().unwrap(); + assert!( + matches!(last.state, SessionState::Error { .. }), + "Detached folds the turn to Error, got {last:?}" + ); + assert!( + !last.has_activity, + "Detached clears the roster → has_activity=false (crash parity with suspend), got {seq:?}" + ); + } + + /// ENUMERATION INVARIANT (anti "isomorphic-branch-not-fully-enumerated" defect): + /// the PROCESS-LOSS equivalence class — events meaning "the process is gone, so the + /// per-ref terminal SubagentUpdate that would terminalize a Running workflow can + /// NEVER arrive" — is `{Detached, BackendSuspended}`. EVERY member MUST clear the + /// roster, else a Running workflow leaves a ghost entry → has_activity stuck true. + /// This is the bug that shipped: fold_one's clear-arm matched only BackendSuspended, + /// Detached (crash) was isomorphic but omitted. A per-source test (only suspend) + /// could not catch it. This table drives a Running workflow then EACH member and + /// asserts has_activity falls false — adding a new process-loss event without a + /// clear-arm trips this. (Cancel is NOT in the class: I10 absorbs the post-cancel + /// Detached. TurnResult is a turn-end, not process loss.) + #[tokio::test] + async fn every_process_loss_event_clears_roster() { + use crate::event::SubagentStatus; + // The process-loss equivalence class, enumerated. Extend this when a new + // "process is gone" SessionEvent is added — and fold_one must clear on it. + let process_loss: Vec<(&str, SessionEvent)> = vec![ + ("BackendSuspended", SessionEvent::BackendSuspended), + ( + "Detached", + SessionEvent::Detached { + exit: None, + redacted_summary: None, + }, + ), + ]; + for (name, loss_event) in process_loss { + let orch = Orchestrator::new(256); + let seq = collect_all_snaps( + &orch, + "pl", + ScriptBackend(vec![ + env("pl", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "pl", + 1, + SessionEvent::SubagentUpdate { + r#ref: "w".into(), + label: None, + status: SubagentStatus::Running, + parent_ref: None, + }, + ), + // The turn ends but the workflow OUTLIVES it (background_active → + // has_activity stays true on Idle). This is the realistic setup the + // process-loss event must then tear down (uniform for both members; + // a mid-turn crash without this TurnResult is also valid but the + // outlive shape exercises the background-plane leak the bug was about). + env( + "pl", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "go".into(), + epoch: 1, + outcome: crate::event::TurnOutcome::default(), + }, + ), + env("pl", 1, loss_event), + ]), + ) + .await; + assert!( + seq.iter().any(|s| s.has_activity), + "[{name}] workflow was running (has_activity=true) before the loss, got {seq:?}" + ); + assert!( + !seq.last().unwrap().has_activity, + "[{name}] process-loss MUST clear the roster → has_activity=false \ + (isomorphic-branch guard: every process-loss event clears, not just suspend), got {seq:?}" + ); + } + } + + /// ENUMERATION INVARIANT (snapshot push-gate, §1.6(3)). The fold loop pushes a full + /// snapshot iff `!transitions.is_empty() || activity_changed` — i.e. on (1) an FSM + /// phase change OR (2) a has_activity edge (foreground requires-action OR background + /// subagent set), and NEVER on (3) an accumulator-only churn (delta flood). Each + /// family had its own test; this pins the whole DECISION in one table so a new + /// push-trigger (or a regression that drops one) is forced through here. The oracle + /// is "did a NEW snapshot arrive attributable to the last event" — measured as the + /// snapshot count strictly growing across the trigger event vs not growing across a + /// no-op event appended to the same prefix. + #[tokio::test] + async fn snapshot_push_gate_fires_on_phase_and_activity_edges_only() { + use crate::event::{PermissionKind, SubagentStatus}; + let ts = |g| env("pg", g, SessionEvent::TurnStarted { epoch: g }); + let delta = || { + env( + "pg", + 1, + SessionEvent::MessageDelta { + item_id: "m".into(), + text: "x".into(), + }, + ) + }; + // Park on a tool permission: Running + requires_action → foreground activity + // is FALSE, so a subsequent subagent edge is OBSERVABLE as a has_activity flip + // (during plain Running, foreground is already true and a subagent wouldn't + // change the bit — the real activity-edge case needs the parked turn). + let park = || { + env( + "pg", + 1, + SessionEvent::Permission { + request_id: "req-1".into(), + kind: PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + ) + }; + let sub = |status| { + env( + "pg", + 1, + SessionEvent::SubagentUpdate { + r#ref: "w".into(), + label: None, + status, + parent_ref: None, + }, + ) + }; + + // Each row: (label, prefix events, the probe event, expect_push?). We run the + // prefix+probe and compare snapshot count to prefix-only — a push ⟺ the count grew. + struct Row { + label: &'static str, + prefix: Vec, + probe: SessionEnvelope, + expect_push: bool, + } + let rows = vec![ + // (1) PHASE change: Idle→Running on TurnStarted. + Row { + label: "phase-change(TurnStarted)", + prefix: vec![], + probe: ts(1), + expect_push: true, + }, + // (2a) ACTIVITY edge ON: parked on approval (foreground false), a subagent + // starts → has_activity flips true (no FSM phase change — pure roster edge). + Row { + label: "activity-on(subagent Running while parked)", + prefix: vec![ts(1), park()], + probe: sub(SubagentStatus::Running), + expect_push: true, + }, + // (2b) ACTIVITY edge OFF: that subagent completes while still parked → + // has_activity flips back false (again no phase change). + Row { + label: "activity-off(subagent Completed while parked)", + prefix: vec![ts(1), park(), sub(SubagentStatus::Running)], + probe: sub(SubagentStatus::Completed), + expect_push: true, + }, + // (3) NO-OP: a MessageDelta after another delta — no phase, no activity edge. + Row { + label: "noop(delta flood)", + prefix: vec![ts(1), delta()], + probe: delta(), + expect_push: false, + }, + ]; + + for Row { + label, + prefix, + probe, + expect_push, + } in rows + { + let base = { + let orch = Orchestrator::new(256); + collect_all_snaps(&orch, "pg", ScriptBackend(prefix.clone())) + .await + .len() + }; + let with_probe = { + let orch = Orchestrator::new(256); + let mut script = prefix.clone(); + script.push(probe); + collect_all_snaps(&orch, "pg", ScriptBackend(script)).await.len() + }; + if expect_push { + assert!( + with_probe > base, + "[{label}] must push a snapshot (count {base} → {with_probe})" + ); + } else { + assert_eq!( + with_probe, base, + "[{label}] must NOT push (accumulator churn is not an edge; count {base} → {with_probe})" + ); + } + } + } + + #[tokio::test] + async fn terminal_workflow_not_resurrected_in_roster_by_late_update() { + // 009 R6 / §11.4 on the BACKGROUND plane: once a roster entry is terminal, + // a late non-terminal SubagentUpdate must NOT re-ignite background activity. + use crate::event::SubagentStatus; + let orch = Orchestrator::new(256); + let seq = collect_all_snaps( + &orch, + "wf2", + ScriptBackend(vec![ + env("wf2", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "wf2", + 1, + SessionEvent::SubagentUpdate { + r#ref: "w".into(), + label: None, + status: SubagentStatus::Running, + parent_ref: None, + }, + ), + env( + "wf2", + 1, + SessionEvent::SubagentUpdate { + r#ref: "w".into(), + label: None, + status: SubagentStatus::Completed, + parent_ref: None, + }, + ), + env( + "wf2", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "ok".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + // late, out-of-order non-terminal update for the already-completed workflow. + env( + "wf2", + 1, + SessionEvent::SubagentUpdate { + r#ref: "w".into(), + label: None, + status: SubagentStatus::Running, + parent_ref: None, + }, + ), + ]), + ) + .await; + let last = seq.last().expect("snapshot"); + assert!( + !last.has_activity, + "a late non-terminal update must NOT resurrect the terminal workflow's background activity, got {last:?}" + ); + } + + /// true→false edge (the other half — the spinner that never stops): a subagent + /// runs (has_activity=true), the turn parks on a permission (still true, subagent + /// active), then the subagent COMPLETES → has_activity=false reaches the + /// subscriber. Without the widening the subscriber is stranded on the true. + #[tokio::test] + async fn subagent_completion_during_requires_action_pushes_has_activity_false() { + use crate::event::{PermissionKind, SubagentStatus}; + let orch = Orchestrator::new(256); + let seq = collect_all_snaps( + &orch, + "sa2", + ScriptBackend(vec![ + env("sa2", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "sa2", + 1, + SessionEvent::SubagentUpdate { + r#ref: "sub-1".into(), + label: Some("worker".into()), + status: SubagentStatus::Running, + parent_ref: None, + }, + ), + // park on a permission while the subagent is still running. + env( + "sa2", + 1, + SessionEvent::Permission { + request_id: "req-1".into(), + kind: PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + ), + // subagent finishes → it is pruned from the roster, has_activity flips + // false (parked, no active subagent left). + env( + "sa2", + 1, + SessionEvent::SubagentUpdate { + r#ref: "sub-1".into(), + label: Some("worker".into()), + status: SubagentStatus::Completed, + parent_ref: None, + }, + ), + ]), + ) + .await; + + // The last snapshot: still Running+requires_action, but has_activity now false + // (the completed subagent was pruned; no active work remains while parked). + let last = seq.last().expect("at least one snapshot"); + assert!( + matches!(&last.state, SessionState::Running { requires_action, .. } if requires_action.waiting_on_approval > 0), + "final snapshot still Running+requires_action, got {seq:?}" + ); + assert!( + !last.has_activity, + "after the subagent completes (pruned), has_activity flips back false — the \ + true→false edge must be PUSHED (spinner stops), got {seq:?}" + ); + // The terminal-status subagent was pruned at the orchestrator layer (I14). + assert!( + matches!(&last.state, SessionState::Running { subagents, .. } if subagents.is_empty()), + "completed subagent pruned from the live roster, got {seq:?}" + ); + } + + /// Interrupted status (challenger edge): a subagent that is `Interrupted` is + /// NEITHER active (any_subagent_active) NOR pruned (only Completed/Errored/ + /// Shutdown are). So interrupting the only running subagent during a parked turn + /// flips has_activity false (it stops counting) yet the entry STAYS in the roster. + #[tokio::test] + async fn interrupted_subagent_flips_activity_false_but_stays_in_roster() { + use crate::event::{PermissionKind, SubagentStatus}; + let orch = Orchestrator::new(256); + let seq = collect_all_snaps( + &orch, + "sa3", + ScriptBackend(vec![ + env("sa3", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "sa3", + 1, + SessionEvent::Permission { + request_id: "req-1".into(), + kind: PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + ), + env( + "sa3", + 1, + SessionEvent::SubagentUpdate { + r#ref: "sub-1".into(), + label: None, + status: SubagentStatus::Running, + parent_ref: None, + }, + ), + env( + "sa3", + 1, + SessionEvent::SubagentUpdate { + r#ref: "sub-1".into(), + label: None, + status: SubagentStatus::Interrupted, + parent_ref: None, + }, + ), + ]), + ) + .await; + + let last = seq.last().expect("a snapshot"); + assert!( + !last.has_activity, + "Interrupted subagent does not count as active → has_activity=false, got {seq:?}" + ); + assert!( + matches!(&last.state, SessionState::Running { subagents, .. } + if subagents.iter().any(|s| matches!(s.status, SubagentStatus::Interrupted))), + "Interrupted is non-terminal → it STAYS in the roster (not pruned), got {seq:?}" + ); + } + + /// LATE subscriber sees fresh has_activity (HOLE-G1-A guard): the activity-only + /// push also updates the `latest` cache, so a subscriber that attaches AFTER the + /// flips seeds the most-recent has_activity, not a stale value. + #[tokio::test] + async fn late_subscriber_seeds_current_has_activity_after_subagent_flips() { + use crate::event::{PermissionKind, SubagentStatus}; + let orch = Orchestrator::new(256); + // Drive a turn that parks then starts a subagent (has_activity ends true), + // collecting through the live subscriber so run() finishes. + let _ = collect_all_snaps( + &orch, + "sa4", + ScriptBackend(vec![ + env("sa4", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "sa4", + 1, + SessionEvent::Permission { + request_id: "r".into(), + kind: PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + ), + env( + "sa4", + 1, + SessionEvent::SubagentUpdate { + r#ref: "sub-1".into(), + label: None, + status: SubagentStatus::Running, + parent_ref: None, + }, + ), + ]), + ) + .await; + + // A LATE subscriber: its first (seed) snapshot must carry the fresh + // has_activity=true (the activity-only push wrote `latest`), not a stale false. + let mut late = orch.subscribe_state("sa4"); + let seed = tokio::time::timeout(std::time::Duration::from_secs(1), late.next()) + .await + .ok() + .flatten() + .expect("late subscriber seeds from latest cache"); + assert!( + seed.has_activity, + "late subscriber seeds the CURRENT has_activity=true (activity-only push refreshed \ + the latest cache — HOLE-G1-A guard), got {seed:?}" + ); + } + + /// Noise guard (the negative): a delta flood within Running produces NO extra + /// activity-only snapshots. `has_foreground_activity` reads only phase + + /// requires_action + the active-subagent set, NOT `saw_substantive_output`, so a + /// MessageDelta flipping that accumulator must NOT widen-push. Exactly the two + /// phase snapshots (Running, Idle) appear — proving the widening did not degrade + /// into a per-delta push. + #[tokio::test] + async fn delta_flood_produces_no_extra_activity_snapshots() { + let orch = Orchestrator::new(256); + let seq = collect_all_snaps( + &orch, + "sa5", + ScriptBackend(vec![ + env("sa5", 1, SessionEvent::TurnStarted { epoch: 1 }), + env( + "sa5", + 1, + SessionEvent::MessageDelta { + item_id: "m".into(), + text: "a".into(), + }, + ), + env( + "sa5", + 1, + SessionEvent::MessageDelta { + item_id: "m".into(), + text: "b".into(), + }, + ), + env( + "sa5", + 1, + SessionEvent::ThoughtDelta { + item_id: "t".into(), + text: "hmm".into(), + }, + ), + env( + "sa5", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "done".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + ]), + ) + .await; + // Exactly two snapshots: the Running phase change + the Idle terminal. The + // three deltas (saw_substantive_output flip on the first MessageDelta) add + // NONE — has_foreground_activity does not read that accumulator. + assert_eq!( + seq.len(), + 2, + "only the 2 phase snapshots (Running, Idle); deltas must NOT widen-push, got {seq:?}" + ); + assert!(matches!(seq[0].state, SessionState::Running { .. }) && !seq[0].can_send); + assert!(matches!(seq[1].state, SessionState::Idle) && seq[1].can_send); + } + + /// Restamp survives the fold: a stale-low-epoch TurnResult arriving during a + /// NEW turn is dropped by the reducer's guard ONLY because the orchestrator + /// stamps the live turn_gen onto the new turn's events. Here we assert the + /// current turn's own unstamped result settles (epoch 0 → restamped to the + /// live turn_gen == since_epoch, so it is NOT dropped). + #[tokio::test] + async fn restamped_current_turn_result_settles_to_idle() { + let orch = Orchestrator::new(256); + let seq = collect_snaps( + &orch, + "x", + ScriptBackend(vec![ + env("x", 5, SessionEvent::TurnStarted { epoch: 5 }), + env( + "x", + 5, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "done".into(), + epoch: 0, // restamped to 5 == since_epoch → settles (not dropped) + outcome: crate::event::TurnOutcome::default(), + }, + ), + ]), + ) + .await; + assert!( + seq.last() + .map(|s| s.can_send && matches!(s.state, SessionState::Idle)) + .unwrap_or(false), + "the current turn's own (unstamped→restamped) result settles to Idle, got {seq:?}" + ); + } + + /// Restamp unit: an unstamped TurnResult (epoch 0) gets the envelope turn_gen. + #[test] + fn restamp_stamps_unstamped_turn_result() { + let mut env = SessionEnvelope { + session_id: "s".into(), + turn_gen: 7, + event: SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "x".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + }; + restamp_epoch(&mut env); + assert!(matches!(env.event, SessionEvent::TurnResult { epoch: 7, .. })); + } + + /// ⭐ THE dispatch↔fold-loop closure (orchestrator-lowers-Command). `send()` + /// dispatches the prompt AND lowers `TurnStarted{epoch:turn_gen}` — NO + /// manually injected TurnStarted, NO backend-produced TurnStarted (I9). We + /// enqueue send() BEFORE run() so the lowered TurnStarted is the first folded + /// event (biased select), faithfully modeling production ordering: a request/ + /// response backend (claude --print) emits NOTHING until it reads the prompt, + /// so its deltas+result always FOLLOW the lowered TurnStarted. The backend's + /// events here (deltas+result, NO TurnStarted) represent that response. + #[tokio::test] + async fn send_lowers_turn_started_and_drives_full_turn() { + let orch = Orchestrator::new(256); + let mut snaps = orch.subscribe_state("s1"); + + // The backend's RESPONSE to the prompt: deltas + result (the request/ + // response wire — NO TurnStarted; that's orchestration-lowered). + let backend = ScriptBackend(vec![ + env( + "s1", + 1, + SessionEvent::MessageDelta { + item_id: "m".into(), + text: "hi".into(), + }, + ), + env( + "s1", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "hi".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + ]); + + // send() BEFORE run(): dispatches (Started{turn_gen:1}) + lowers + // TurnStarted into the mpsc. run()'s biased select folds it before the + // backend events. + let receipt = orch + .send( + &backend, + "s1", + vec![crate::backend::types::ContentBlock::Text("hello".into())], + crate::backend::types::CommandMeta::default(), + ) + .await + .expect("send accepted"); + assert_eq!(receipt.turn_gen, 1); + + let run = { + let orch = orch.clone(); + tokio::spawn(async move { orch.run(&backend).await }) + }; + + let mut saw_running_locked = false; + let mut saw_idle_unlocked = false; + for _ in 0..20 { + match tokio::time::timeout(std::time::Duration::from_secs(2), snaps.next()).await { + Ok(Some(s)) => { + assert_eq!(s.session_id, "s1"); + if matches!(s.state, SessionState::Running { .. }) && !s.can_send { + saw_running_locked = true; + } + if matches!(s.state, SessionState::Idle) && s.can_send { + saw_idle_unlocked = true; + break; + } + } + _ => break, + } + } + let _ = run.await; + assert!( + saw_running_locked, + "send() lowered TurnStarted → Running, can_send=false" + ); + assert!( + saw_idle_unlocked, + "backend result folded → Idle, can_send=true (unlock)" + ); + } + + /// `cancel()` lowers `SessionEvent::Cancel` → the FSM folds Running→Idle + /// immediately, WITHOUT a backend terminal (UI unlocks at once, §004 S14). + /// The backend here emits NO terminal (only a delta) — so the ONLY thing that + /// can reach Idle is the lowered Cancel. + #[tokio::test] + async fn cancel_lowers_and_unlocks_without_backend_terminal() { + let orch = Orchestrator::new(256); + let mut snaps = orch.subscribe_state("s2"); + + // Backend response with NO terminal — just a delta (turn would hang on the + // backend forever). Only the lowered Cancel can unlock. + let backend = ScriptBackend(vec![env( + "s2", + 1, + SessionEvent::MessageDelta { + item_id: "m".into(), + text: "thinking".into(), + }, + )]); + + // send() lowers TurnStarted, THEN cancel() lowers Cancel — both enqueued + // before run() drains them (biased, in-order). + orch.send( + &backend, + "s2", + vec![crate::backend::types::ContentBlock::Text("go".into())], + crate::backend::types::CommandMeta::default(), + ) + .await + .expect("send"); + orch.cancel(&backend, "s2", crate::backend::types::CancelTarget::Turn) + .await + .expect("cancel"); + + let run = { + let orch = orch.clone(); + tokio::spawn(async move { orch.run(&backend).await }) + }; + + let mut saw_running = false; + let mut saw_idle_unlocked = false; + for _ in 0..20 { + match tokio::time::timeout(std::time::Duration::from_secs(2), snaps.next()).await { + Ok(Some(s)) => { + if matches!(s.state, SessionState::Running { .. }) { + saw_running = true; + } + if matches!(s.state, SessionState::Idle) && s.can_send { + saw_idle_unlocked = true; + break; + } + } + _ => break, + } + } + let _ = run.await; + assert!(saw_running, "send lowered TurnStarted → Running"); + assert!( + saw_idle_unlocked, + "cancel() lowered Cancel → Idle + unlock, with NO backend terminal" + ); + } + + // ====================================================================== + // L3 broadcast/subscription TIMING tests (timing-coverage audit G1/G8/G14). + // Ported from the prior framework's runtime_broadcast_lag.rs methodology: + // small CAP to force Lagged + a deliberately-slow consumer + a timeout- + // bounded relay-like consume so a hang surfaces as "no unlock seen". + // ====================================================================== + + const LAG_CAP: usize = 16; + const RECV_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + + /// Consume the state-snapshot stream like a real (slow) UI relay: loop recv, + /// Lagged→count+continue, stop at the unlock (Idle+can_send) or Closed/timeout. + /// Returns (saw_unlock, lagged_total). A genuine "unlock dropped by lag" hang + /// surfaces as saw_unlock=false (timeout), NOT a blocked test. + async fn slow_consume_until_unlock( + mut snaps: BoxStream<'static, StateSnapshot>, + per_event_delay: std::time::Duration, + ) -> (bool, usize) { + let mut saw_unlock = false; + let mut got = 0usize; + loop { + match tokio::time::timeout(RECV_TIMEOUT, snaps.next()).await { + Ok(Some(s)) => { + got += 1; + // Simulate a slow subscriber: yield/sleep between receives so + // the broadcast ring overwrites un-consumed snapshots. + tokio::time::sleep(per_event_delay).await; + if s.can_send && matches!(s.state, SessionState::Idle) { + saw_unlock = true; + break; + } + } + Ok(None) => break, // stream closed + Err(_) => break, // timed out — unlock never arrived (would-be hang) + } + } + (saw_unlock, got) + } + + /// 🔴 G1 — the unlock snapshot must survive broadcast lag. A turn's terminal + /// Idle(can_send=true) snapshot is emitted EXACTLY ONCE and is the SOLE unlock + /// path (§C7). If a slow subscriber lags AND events are emitted AFTER the + /// unlock (more deltas, or another session's snapshots flooding the shared + /// state_tx), the unlock can age out of the ring → UI locked forever. + /// + /// This mirrors runtime_broadcast_lag's `terminal_lost_only_if_events_follow_it` + /// shape. We drive ONE session to its unlock, then FLOOD the SAME state_tx with + /// a second session's transitions (>cap), then let the slow consumer drain. + /// The §C7 contract REQUIRES the first session's subscriber still observes its + /// unlock — a state subscriber is demuxed by session_id, so the flood is noise + /// it must skip without losing its own terminal. + #[tokio::test] + async fn g1_unlock_snapshot_survives_lag_with_events_after() { + let orch = Orchestrator::new(LAG_CAP); + // Subscribe to session "s1" BEFORE anything runs. + let snaps_s1 = orch.subscribe_state("s1"); + + // Backend for s1: a clean turn (TurnStarted lowered by send → result). + let s1_backend = ScriptBackend(vec![env( + "s1", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "done".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + )]); + orch.send( + &s1_backend, + "s1", + vec![crate::backend::types::ContentBlock::Text("go".into())], + crate::backend::types::CommandMeta::default(), + ) + .await + .expect("send"); + + // A SECOND session floods the SAME state_tx with many transitions AFTER + // s1's unlock — this is what can overwrite s1's single unlock in the ring. + // We push these directly onto the shared state broadcast to model the + // multiplexed-session flood deterministically. + let run = { + let orch = orch.clone(); + tokio::spawn(async move { + orch.run(&s1_backend).await; + // After s1's turn folded (unlock emitted), flood the shared ring + // with >cap unrelated snapshots so the slow consumer lags past it. + for i in 0..(LAG_CAP * 4) { + let _ = orch.state_tx.send(StateSnapshot { + session_id: "s2".into(), + state: SessionState::Running { + since_epoch: i as u64, + saw_substantive_output: false, + terminal_result_seen: false, + requires_action: Default::default(), + subagents: Vec::new(), + }, + can_send: false, + has_activity: true, + can_queue: false, // flood noise (another session); never asserted + can_cancel: true, // Running + turn_gen: i as u64, + last_reason: None, + }); + } + }) + }; + + // Slow consumer: 5ms between receives → guarantees it lags behind the flood. + let (saw_unlock, _got) = slow_consume_until_unlock(snaps_s1, std::time::Duration::from_millis(5)).await; + let _ = run.await; + + assert!( + saw_unlock, + "G1: s1's unlock snapshot (Idle,can_send=true) MUST survive broadcast lag + a \ + post-unlock flood from another session — else the UI hangs locked forever. \ + If this fails, the fix is a Lagged-recheck or sticky last-snapshot-on-subscribe." + ); + } + + /// 🔴 Gap-3 — `subscribe_events` must SURFACE broadcast lag as a + /// `SessionEvent::Lagged{skipped}`, not silently swallow it. Before the fix the + /// event demux did `RecvError::Lagged(_) => continue`, so a slow consumer that + /// overflowed the ring lost deltas with NO signal it had a hole. Here we flood + /// the event ring past `cap` while a subscriber is parked, then drain and assert + /// a `Lagged` envelope (skipped>0) for this session arrives. + #[tokio::test] + async fn subscribe_events_surfaces_lagged_on_broadcast_overflow() { + let orch = Orchestrator::new(LAG_CAP); + // Park a subscriber, then flood WITHOUT consuming so its receiver lags. + let mut events = orch.subscribe_events("s1"); + let tx = orch.event_tx_for_test(); + for i in 0..(LAG_CAP * 4) { + let _ = tx.send(SessionEnvelope { + session_id: "s1".into(), + turn_gen: 0, + event: SessionEvent::MessageDelta { + item_id: "m1".into(), + text: format!("d{i}"), + }, + }); + } + // Drain: the first item the parked receiver yields after overflow is the + // synthesized Lagged (the ring dropped the early deltas). + let mut saw_lagged = None; + for _ in 0..(LAG_CAP * 4 + 4) { + match tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await { + Ok(Some(env)) => { + if let SessionEvent::Lagged { skipped } = env.event { + saw_lagged = Some(skipped); + break; + } + } + _ => break, + } + } + let skipped = saw_lagged.expect( + "subscribe_events must surface a SessionEvent::Lagged when the broadcast ring \ + overflows a slow subscriber — not silently continue (the Gap-3 silent-swallow defect)", + ); + assert!( + skipped > 0, + "Lagged.skipped reports how many events were dropped, got {skipped}" + ); + } + + /// 🟡 G8 (FIXED) — a late/reconnect subscriber joining mid-turn immediately + /// learns the current phase from the seeded latest-snapshot, instead of being + /// blind until the next transition. This is the G8 fix (subscribe_state seeds + /// its first item from the per-session `latest` cache, Addendum 8 reconnect). + #[tokio::test] + async fn g8_late_subscriber_gets_seeded_running_snapshot() { + let orch = Orchestrator::new(256); + // A backend that goes Running then STAYS (no terminal): only a delta. + let backend = ScriptBackend(vec![env( + "s3", + 1, + SessionEvent::MessageDelta { + item_id: "m".into(), + text: "streaming".into(), + }, + )]); + orch.send( + &backend, + "s3", + vec![crate::backend::types::ContentBlock::Text("go".into())], + crate::backend::types::CommandMeta::default(), + ) + .await + .expect("send"); + let run = { + let orch = orch.clone(); + tokio::spawn(async move { orch.run(&backend).await }) + }; + // Let the turn reach Running, THEN subscribe late. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let mut late = orch.subscribe_state("s3"); + // The late subscriber's FIRST snapshot is the seeded current state: + // Running(can_send=false) — NOT blind, NOT stale-unlocked. + let first = tokio::time::timeout(std::time::Duration::from_millis(500), late.next()).await; + run.abort(); + match first { + Ok(Some(s)) => { + assert!( + matches!(s.state, SessionState::Running { .. }) && !s.can_send, + "G8: late subscriber's first (seeded) snapshot is Running(can_send=false), got {s:?}" + ); + } + other => panic!("G8: late subscriber must get a seeded Running snapshot, got {other:?}"), + } + } + + /// 🟡 G14 — Cancel racing a backend terminal: exactly ONE Idle snapshot, and + /// the outcome is Idle (cancel folds first, the backend TurnResult is absorbed + /// by I10). Mutation target: removing `biased` from run()'s select must break + /// the ordering guarantee this relies on. + #[tokio::test] + async fn g14_cancel_with_racing_backend_terminal_yields_one_idle() { + let orch = Orchestrator::new(256); + let mut snaps = orch.subscribe_state("s4"); + // Backend emits a delta THEN an immediate TurnResult — racing the lowered + // Cancel. The Cancel is lowered before run() drains, biased-folded first + // (Running→Idle); the backend TurnResult then arrives while Idle → I10 + // absorbs it (no second transition, no second snapshot). + let backend = ScriptBackend(vec![ + env( + "s4", + 1, + SessionEvent::MessageDelta { + item_id: "m".into(), + text: "partial".into(), + }, + ), + env( + "s4", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "late".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + ), + ]); + orch.send( + &backend, + "s4", + vec![crate::backend::types::ContentBlock::Text("go".into())], + crate::backend::types::CommandMeta::default(), + ) + .await + .expect("send"); + orch.cancel(&backend, "s4", crate::backend::types::CancelTarget::Turn) + .await + .expect("cancel"); + let run = { + let orch = orch.clone(); + tokio::spawn(async move { orch.run(&backend).await }) + }; + + // Collect all snapshots until the stream closes (backend stream ends). + let mut idle_unlocked = 0usize; + let mut final_state_idle = false; + for _ in 0..30 { + match tokio::time::timeout(std::time::Duration::from_millis(300), snaps.next()).await { + Ok(Some(s)) => { + if matches!(s.state, SessionState::Idle) && s.can_send { + idle_unlocked += 1; + final_state_idle = true; + } + // an Error snapshot here would mean the late TurnResult wrongly + // settled — assert it never happens. + assert!( + !matches!(s.state, SessionState::Error { .. }), + "G14: a Cancel-then-late-TurnResult must NEVER settle as Error (I10 absorbs it)" + ); + } + _ => break, + } + } + run.abort(); + assert!(final_state_idle, "G14: cancel yields a final Idle(unlock) snapshot"); + assert_eq!( + idle_unlocked, 1, + "G14: exactly ONE Idle-unlock snapshot (the Cancel's); the racing backend \ + TurnResult is absorbed by I10 → no second transition/snapshot. Removing `biased` \ + from run()'s select should break this." + ); + } + + /// Flood the SHARED state_tx with N snapshots from an UNRELATED session to + /// force a `Lagged` on a slow subscriber to a DIFFERENT session. + fn flood_other_session(orch: &Orchestrator, sid: &str, n: usize) { + for i in 0..n { + let _ = orch.state_tx.send(StateSnapshot { + session_id: sid.into(), + state: SessionState::Running { + since_epoch: i as u64, + saw_substantive_output: false, + terminal_result_seen: false, + requires_action: Default::default(), + subagents: Vec::new(), + }, + can_send: false, + has_activity: true, + can_queue: false, // flood noise (another session); never asserted + can_cancel: true, // Running + turn_gen: i as u64, + last_reason: None, + }); + } + } + + /// ⭐ HOLE-G1-A regression guard (found by the G1 verification workflow). A + /// subscriber that subscribes to a session BEFORE that session's first + /// transition has latest[sid]=None. If the shared ring then Lags (flooded by + /// other sessions) and the session never produces an own event (e.g. a Queued + /// send that never lowered TurnStarted), the empty-cache Lagged arm used to + /// `continue`-spin forever → permanent UI lock. The fix synthesizes a truthful + /// initial Idle(can_send=true). This test was RED before the fix. + #[tokio::test] + async fn g1a_subscribed_before_first_transition_then_lag_recovers_initial_idle() { + let orch = Orchestrator::new(LAG_CAP); + // Subscribe to "s1" BEFORE any s1 transition (latest["s1"] = None). + let mut snaps = orch.subscribe_state("s1"); + // Flood a DIFFERENT session past cap → forces Lagged on s1's receiver, + // while s1 itself never transitions (models a Queued/never-started send). + flood_other_session(&orch, "s2", LAG_CAP * 4); + // s1's subscriber must NOT hang: the empty-cache Lagged recovers a + // truthful initial Idle(can_send=true) instead of spinning forever. + let first = tokio::time::timeout(std::time::Duration::from_secs(2), snaps.next()).await; + match first { + Ok(Some(s)) => { + assert_eq!(s.session_id, "s1", "demux: only s1 snapshots"); + assert!( + s.can_send && matches!(s.state, SessionState::Idle), + "G1-A: empty-cache Lagged must recover initial Idle(can_send=true), got {s:?}" + ); + } + other => panic!("G1-A: subscriber hung on empty-cache Lagged (the bug) — got {other:?}"), + } + } + + /// EC-1 hardened (L1 liveness, the core G1 risk stated as the invariant): + /// a subscriber that HAS seen its session's transitions recovers the unlock + /// from the cache even when a post-unlock flood from another session laps the + /// ring. (This is g1_unlock_snapshot_survives_lag_with_events_after re-asserted + /// as L1; kept separate so the invariant is named.) + #[tokio::test] + async fn g1_l1_unlock_recoverable_after_own_transition_and_flood() { + let orch = Orchestrator::new(LAG_CAP); + let snaps = orch.subscribe_state("s1"); + let backend = ScriptBackend(vec![env( + "s1", + 1, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "done".into(), + epoch: 0, + outcome: crate::event::TurnOutcome::default(), + }, + )]); + orch.send( + &backend, + "s1", + vec![crate::backend::types::ContentBlock::Text("go".into())], + crate::backend::types::CommandMeta::default(), + ) + .await + .expect("send"); + let run = { + let orch = orch.clone(); + tokio::spawn(async move { + orch.run(&backend).await; + // post-unlock flood from another session laps the ring. + flood_other_session(&orch, "s2", LAG_CAP * 4); + }) + }; + let (saw_unlock, _) = slow_consume_until_unlock(snaps, std::time::Duration::from_millis(5)).await; + let _ = run.await; + assert!( + saw_unlock, + "L1: unlock recoverable via latest-cache after own transition + flood" + ); + } +} diff --git a/crates/aionui-session/src/backend/rehydrate.rs b/crates/aionui-session/src/backend/rehydrate.rs new file mode 100644 index 000000000..fdc6e7ee1 --- /dev/null +++ b/crates/aionui-session/src/backend/rehydrate.rs @@ -0,0 +1,61 @@ +//! 007 §C4 / §7.3: cold-start `SessionState` rebuild from a `Tier2Checkpoint`. +//! +//! ⭐ COLLAPSED per conversation C §17 (user-decided 2026-06-11 "1A"): the +//! persisted Tier-2 state is a SINGLE column (`conversations.backend_session_id`) +//! plus `turn_gen` for epoch continuity. Every FSM mid-turn substate +//! (outstanding permissions, last outcome, subagent roster, process-attached, +//! workflow disk-tail) is turn-INTERNAL transient and is NOT persisted — a crash +//! loses it (accepted). Therefore cold recovery is ALWAYS `Idle`: an existing +//! session reopens at a clean turn boundary, the caller resumes the backend via +//! `SessionSpec::Resume { backend_session_id }`, and the user re-drives any +//! in-flight work. The resume anchor + epoch do NOT change the rebuilt phase. +//! +//! This is the COLD-START function (backend not yet up, nothing emitted). HOT +//! rewind is a different path: `Command::Rewind` → backend mutates history → +//! `SessionEvent::Rewound{to_turn}` → the orchestrator drops post-`to_turn` +//! display blocks and (since the FSM is Idle-only at boundaries) leaves the live +//! FSM at Idle (§9.9). No turn-outcome read-back is needed — rewind is a +//! user-initiated, idle-only operation, and a rewound session is Idle. + +use super::types::Tier2Checkpoint; +use crate::state::SessionState; + +/// Rebuild `SessionState` from a checkpoint. Pure: no I/O, no clock, no deltas. +/// +/// Rule (§7.3, collapsed C §17): cold start is `Idle`. The checkpoint carries the +/// resume anchor + epoch for CONTINUITY (the caller threads `backend_session_id` +/// into `SessionSpec::Resume` and the adapter resumes at `turn_gen + 1`), not for +/// phase reconstruction. A crash that happened mid-turn (pending permission, +/// in-flight tool) recovers to `Idle` — the transient substate is gone (accepted); +/// the user re-issues the action. +pub fn rehydrate(_ck: &Tier2Checkpoint) -> SessionState { + SessionState::Idle +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ck(backend_session_id: Option<&str>, turn_gen: u64) -> Tier2Checkpoint { + Tier2Checkpoint { + session_id: "s1".into(), + backend_session_id: backend_session_id.map(str::to_string), + turn_gen, + } + } + + #[test] + fn fresh_checkpoint_is_idle() { + // No prior backend session, epoch 0 → a never-run session rebuilds Idle. + assert_eq!(rehydrate(&ck(None, 0)), SessionState::Idle); + } + + #[test] + fn resumable_checkpoint_is_idle_anchor_does_not_change_phase() { + // A checkpoint with a live resume anchor + advanced epoch STILL rebuilds + // Idle (collapsed C §17): the anchor is for SessionSpec::Resume, not for + // phase. The mid-turn substate that the old rich checkpoint restored + // (pending-permission badge, roster) is intentionally NOT recovered. + assert_eq!(rehydrate(&ck(Some("thread-abc"), 7)), SessionState::Idle); + } +} diff --git a/crates/aionui-session/src/backend/suspend.rs b/crates/aionui-session/src/backend/suspend.rs new file mode 100644 index 000000000..4ade7f163 --- /dev/null +++ b/crates/aionui-session/src/backend/suspend.rs @@ -0,0 +1,408 @@ +//! Self-suspending process controller (007 §F-4 / P0c F-4). +//! +//! A backend that owns a spawned process can let it go idle: after `idle_ttl` +//! with no dispatch, the process is closed (→ Dormant); the next dispatch +//! re-spawns it (with the backend's own `--resume`/`session/load`/`thread/resume` +//! handshake, so the session is logically continuous). The whole suspend/wake +//! dance happens behind ONE lock (`slot`), so there is a single actor and no +//! TOCTOU between "the idle timer closes it" and "a dispatch wakes it" — the +//! design's load-bearing invariant. +//! +//! This controller holds ONLY the process-bound pair `{reader, io}` (a suspend +//! drops them; a wake recreates them). Everything that must survive a suspend — +//! the shared `stdin`, the `event_tx` broadcast, `turn_gen`, and the backend +//! session-id binding (the resume anchor) — stays on the backend itself; the +//! backend's `wake` closure repopulates the shared `stdin` and spawns a fresh +//! reader on the SAME `event_tx`, so subscribers and the FSM never notice. +//! +//! ## OFF by default (production parity) +//! `idle_ttl_ms = None` means "never suspend": the idle timer is not spawned and +//! the slot stays Active for the backend's whole lifetime. In that mode the only +//! added cost per dispatch is one uncontended async-mutex lock + one atomic store +//! — the wire/parse output is byte-identical to the pre-F-4 backend. Suspension +//! is opt-in (a configured ttl), so the hard "claude parse zero-diff" acceptance +//! is unaffected unless a ttl is explicitly set. +//! +//! 007 impact: zero. This lives entirely inside the backend impls (adapter-private +//! interior mutability); it does not touch the reducer, SessionEvent, or the +//! `SessionBackend` trait. The FSM never sees suspend/wake — `Idle` already +//! absorbs everything, and whether the process sleeps underneath is a +//! resource-layer concern. + +use std::future::Future; +use std::sync::Arc; +use std::sync::atomic::{AtomicI64, Ordering}; + +use tokio::sync::Mutex; +use tokio::task::{AbortHandle, JoinHandle}; + +use super::types::BackendError; +use crate::adapter::AgentIo; + +/// The process-bound pair a suspend drops and a wake recreates: the reader task +/// draining stdout, and the `AgentIo` whose last clone-drop reaps the child +/// (`kill_on_drop`). Aborting the reader releases its `io` clone; dropping the +/// slot's `io` then reaps the process. +pub struct ProcHandle { + pub reader: JoinHandle<()>, + /// Held only as a drop-guard: this is the slot's strong `AgentIo` clone. It is + /// never read directly — its job is to keep (and, when the slot drops/suspends, + /// release) the last reference so the `ManagedProcess` is reaped (`kill_on_drop`). + #[allow(dead_code)] + pub io: Arc, +} + +impl ProcHandle { + pub fn new(reader: JoinHandle<()>, io: Arc) -> Self { + Self { reader, io } + } +} + +/// Active (process live) ⇄ Dormant (closed; respawns on next dispatch). +enum Slot { + Active(ProcHandle), + Dormant, +} + +impl Slot { + fn is_active(&self) -> bool { + matches!(self, Slot::Active(_)) + } +} + +/// Self-suspending process lifecycle, shared (`Arc`) between the backend and its +/// idle-timer task. Single-actor: suspend and wake both take `slot`'s lock, so a +/// timer-driven close can never race a dispatch-driven wake (no TOCTOU). +pub struct SuspendController { + slot: Mutex, + /// Last dispatch time (ms). Read by the idle check, written on every wake/note. + last_activity: AtomicI64, + /// None = never suspend (production default). Some(ttl) = close after `ttl` ms idle. + idle_ttl_ms: Option, + /// The current reader's abort handle, mirrored here so the backend's sync + /// `Drop` can abort the reader WITHOUT awaiting `slot`'s async lock. Updated + /// under `slot`'s lock on every wake/suspend so it always tracks the live reader. + current_abort: std::sync::Mutex>, +} + +impl SuspendController { + /// Start Active with an already-spawned process pair. + pub fn active(handle: ProcHandle, idle_ttl_ms: Option, now_ms: i64) -> Self { + let abort = handle.reader.abort_handle(); + Self { + slot: Mutex::new(Slot::Active(handle)), + last_activity: AtomicI64::new(now_ms), + idle_ttl_ms, + current_abort: std::sync::Mutex::new(Some(abort)), + } + } + + /// The configured idle TTL (None = never suspend). Test/diagnostic. + #[cfg(test)] + pub fn idle_ttl_ms(&self) -> Option { + self.idle_ttl_ms + } + + /// Last dispatch time (ms). Test/diagnostic. + #[cfg(test)] + pub fn last_activity(&self) -> i64 { + self.last_activity.load(Ordering::SeqCst) + } + + /// Ensure the process is live, spawning (resuming) it under the slot lock if + /// Dormant, then refresh `last_activity`. `wake` re-runs the backend's own + /// spawn+handshake (repopulating the shared stdin as a side effect) and + /// returns the fresh `{reader, io}`. Held across the wake so a concurrent + /// `suspend_if_idle` cannot interleave (single-actor). A wake `Err` leaves the + /// slot Dormant (no half-spawned state). + pub async fn ensure_awake(&self, now_ms: i64, wake: F) -> Result<(), BackendError> + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let mut slot = self.slot.lock().await; + if !slot.is_active() { + let handle = wake().await?; + *self.current_abort.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle.reader.abort_handle()); + *slot = Slot::Active(handle); + } + self.last_activity.store(now_ms, Ordering::SeqCst); + Ok(()) + } + + /// If Active, NOT in a live turn, AND idle past `idle_ttl_ms`, close the + /// process (abort reader → drop io → `kill_on_drop`) and go Dormant. Returns + /// whether it suspended. No-op when `idle_ttl_ms` is None. + /// + /// `turn_active` is the load-bearing safety gate: a turn can stream/run tools + /// for longer than the idle ttl (normal for a coding agent) without bumping + /// `last_activity` (which only moves on dispatch), so without this guard the + /// idle timer would abort the reader MID-TURN — severing the in-flight turn + /// with no terminal and stranding the FSM in Running forever. The backend sets + /// `turn_active` true on `dispatch(Send)` and the reader clears it at the + /// turn's terminal; the timer passes it here so an in-flight turn is never + /// suspended. (A hung/never-terminating turn therefore also stays resident — + /// idle-reap is for IDLE sessions; stuck-Running is an orthogonal concern.) + /// + /// Taking the SAME lock as `ensure_awake` is what makes close and wake + /// mutually exclusive: a dispatch that just refreshed `last_activity` fails the + /// idle check and nothing is closed. + pub async fn suspend_if_idle(&self, now_ms: i64, turn_active: bool) -> bool { + let Some(ttl) = self.idle_ttl_ms else { + return false; + }; + if turn_active { + return false; // never suspend a live turn (the reader is producing output) + } + let mut slot = self.slot.lock().await; + if slot.is_active() && now_ms - self.last_activity.load(Ordering::SeqCst) >= ttl { + if let Slot::Active(handle) = std::mem::replace(&mut *slot, Slot::Dormant) { + handle.reader.abort(); // release the io clone the reader holds + // `handle.io` drops here → the ManagedProcess is reaped + // (kill_on_drop). The backend swaps in fresh stdin on the next wake, + // so the now-dangling old stdin is released when its process dies. + } + *self.current_abort.lock().unwrap_or_else(|e| e.into_inner()) = None; + return true; + } + false + } + + /// Sync teardown for the backend's `Drop`: abort the live reader (if any) so + /// its `AgentIo` clone releases and the child is reaped. Does NOT touch the + /// async `slot` (Drop cannot await); the mirrored `AbortHandle` is enough. + pub fn abort_on_drop(&self) { + if let Some(h) = self.current_abort.lock().unwrap_or_else(|e| e.into_inner()).take() { + h.abort(); + } + } + + /// Test/diagnostic: is the process currently live? + #[cfg(test)] + pub async fn is_active(&self) -> bool { + self.slot.lock().await.is_active() + } + + /// Test-only: the live reader's abort handle (mirrors the slot's reader), so a + /// backend test can assert drop/suspend actually aborted it. + #[cfg(test)] + pub fn current_abort_handle(&self) -> Option { + self.current_abort.lock().unwrap_or_else(|e| e.into_inner()).clone() + } +} + +/// Spawn the per-backend idle timer: every `check_interval_ms`, ask the +/// controller to suspend if idle AND no turn is in flight. Returns None when +/// `idle_ttl_ms` is None (the production default — no timer, no suspension). The +/// task holds a `Weak` so it exits once the backend (and its controller) drops; +/// the backend also aborts the returned handle in its `Drop` (belt-and-suspenders). +/// `now` is injected for determinism in tests; `turn_active` reports whether a +/// turn is currently in flight (the backend's live turn flag) so a streaming turn +/// is never suspended mid-flight. +pub fn spawn_idle_timer( + controller: &Arc, + check_interval_ms: u64, + now: N, + turn_active: T, + on_suspend: S, +) -> Option> +where + N: Fn() -> i64 + Send + 'static, + T: Fn() -> bool + Send + 'static, + // 009 R6: fired ONCE each time an idle-reap actually suspends. The backend + // passes a closure that emits `SessionEvent::BackendSuspended` on its event_tx + // so the orchestrator can clear the workflow_roster (cleanup path 3). Kept as a + // callback so suspend.rs stays decoupled from SessionEvent (FSM-invisible). + S: Fn() + Send + 'static, +{ + controller.idle_ttl_ms?; + let weak = Arc::downgrade(controller); + Some(tokio::spawn(async move { + let interval = std::time::Duration::from_millis(check_interval_ms.max(1)); + loop { + tokio::time::sleep(interval).await; + let Some(ctrl) = weak.upgrade() else { + break; // backend dropped → stop the timer + }; + if ctrl.suspend_if_idle(now(), turn_active()).await { + on_suspend(); + } + } + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::FakeAgentIo; + + /// Build a `ProcHandle` over a never-exiting fake process + a reader task that + /// just parks (so abort is observable). Returns the handle + an abort handle to + /// assert the reader's liveness. + fn fake_handle() -> (ProcHandle, AbortHandle) { + let io: Arc = Arc::from(Box::new(FakeAgentIo::never_exits(Vec::new())) as Box); + let reader = tokio::spawn(async { + std::future::pending::<()>().await; + }); + let abort = reader.abort_handle(); + (ProcHandle::new(reader, io), abort) + } + + #[tokio::test] + async fn off_by_default_never_suspends() { + let (h, _a) = fake_handle(); + let ctrl = SuspendController::active(h, None, 0); + assert!(ctrl.is_active().await); + // Even long past any ttl, suspend is a no-op when idle_ttl is None. + assert!(!ctrl.suspend_if_idle(1_000_000, false).await); + assert!( + ctrl.is_active().await, + "None ttl → stays Active forever (production parity)" + ); + } + + #[tokio::test] + async fn suspends_after_ttl_then_wakes() { + let (h, abort0) = fake_handle(); + let ctrl = SuspendController::active(h, Some(100), 0); + + // Not idle yet (50 < 100) → no suspend. + assert!(!ctrl.suspend_if_idle(50, false).await); + assert!(ctrl.is_active().await); + + // Idle past ttl → suspends + aborts the reader. + assert!(ctrl.suspend_if_idle(150, false).await); + assert!(!ctrl.is_active().await); + for _ in 0..40 { + if abort0.is_finished() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!( + abort0.is_finished(), + "suspend aborts the old reader (releases its io clone)" + ); + + // Next dispatch wakes via the backend's wake closure. + let woke = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let w = woke.clone(); + ctrl.ensure_awake(200, || async move { + w.store(true, Ordering::SeqCst); + let (h2, _) = fake_handle(); + Ok(h2) + }) + .await + .unwrap(); + assert!(woke.load(Ordering::SeqCst), "Dormant → wake closure ran"); + assert!(ctrl.is_active().await, "woke back to Active"); + assert_eq!(ctrl.last_activity(), 200); + } + + #[tokio::test] + async fn recent_dispatch_blocks_suspend_no_toctou() { + // The single-actor invariant: a dispatch that just refreshed last_activity + // (via ensure_awake on an already-Active slot) prevents an idle-close. + let (h, _a) = fake_handle(); + let ctrl = SuspendController::active(h, Some(100), 0); + // ensure_awake on an Active slot just refreshes last_activity (no wake). + ctrl.ensure_awake(100, || async { panic!("must not wake an Active slot") }) + .await + .unwrap(); + // last_activity=100; idle check at 150 sees only 50ms → must NOT close. + assert!(!ctrl.suspend_if_idle(150, false).await); + assert!(ctrl.is_active().await); + } + + #[tokio::test] + async fn turn_active_blocks_suspend_even_when_idle() { + // The #1-critical guard: a long-running turn keeps last_activity pinned at + // dispatch time (it only moves on Send), so without the turn_active gate the + // idle timer would abort the reader mid-turn. With the gate, an idle-past-ttl + // check that reports turn_active=true must NOT close the slot. + let (h, _a) = fake_handle(); + let ctrl = SuspendController::active(h, Some(100), 0); + // Way past the ttl, but a turn is in flight → must stay Active. + assert!(!ctrl.suspend_if_idle(10_000, true).await, "never suspend a live turn"); + assert!(ctrl.is_active().await, "live turn kept the process resident"); + // Once the turn ends (turn_active=false) the same idle check suspends. + assert!(ctrl.suspend_if_idle(10_000, false).await); + assert!(!ctrl.is_active().await); + } + + #[tokio::test] + async fn wake_failure_leaves_dormant() { + let (h, _a) = fake_handle(); + let ctrl = SuspendController::active(h, Some(100), 0); + assert!(ctrl.suspend_if_idle(200, false).await); + let r = ctrl + .ensure_awake(300, || async { Err(BackendError::Transport("boom".into())) }) + .await; + assert!(matches!(r, Err(BackendError::Transport(_)))); + assert!( + !ctrl.is_active().await, + "failed wake leaves the slot Dormant, not half-spawned" + ); + } + + #[tokio::test] + async fn abort_on_drop_aborts_live_reader() { + let (h, abort0) = fake_handle(); + let ctrl = SuspendController::active(h, None, 0); + assert!(!abort0.is_finished()); + ctrl.abort_on_drop(); + for _ in 0..40 { + if abort0.is_finished() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(abort0.is_finished(), "abort_on_drop aborts the live reader"); + } + + #[tokio::test] + async fn idle_timer_not_spawned_when_off() { + let (h, _a) = fake_handle(); + let ctrl = Arc::new(SuspendController::active(h, None, 0)); + assert!( + spawn_idle_timer(&ctrl, 10, || 0, || false, || {}).is_none(), + "no timer when idle_ttl is None" + ); + } + + #[tokio::test] + async fn idle_timer_suspends_when_on() { + let (h, abort0) = fake_handle(); + let ctrl = Arc::new(SuspendController::active(h, Some(20), 0)); + // A monotonically-advancing clock so the timer eventually crosses the ttl. + let clock = Arc::new(AtomicI64::new(0)); + let c = clock.clone(); + // 009 R6: the on_suspend callback must fire exactly when an idle-reap happens. + let fired = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let f = fired.clone(); + let timer = spawn_idle_timer( + &ctrl, + 5, + move || c.fetch_add(50, Ordering::SeqCst) + 50, + || false, + move || f.store(true, Ordering::SeqCst), + ) + .expect("timer spawned when ttl is Some"); + // Within ~1s the timer must observe idle and suspend. + let mut suspended = false; + for _ in 0..100 { + if !ctrl.is_active().await { + suspended = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + timer.abort(); + assert!(suspended, "the idle timer suspended the idle process"); + assert!(abort0.is_finished(), "timer-driven suspend aborted the reader"); + assert!( + fired.load(Ordering::SeqCst), + "009 R6: on_suspend fired on the idle-reap" + ); + } +} diff --git a/crates/aionui-session/src/backend/types.rs b/crates/aionui-session/src/backend/types.rs new file mode 100644 index 000000000..5c834f2ad --- /dev/null +++ b/crates/aionui-session/src/backend/types.rs @@ -0,0 +1,404 @@ +//! 007 §C1 frozen seam types — the a-side vocabulary the conversation layer +//! sends DOWN (`Command`) and the orchestration types that flow back UP +//! (`SessionEnvelope`, `StateSnapshot`). These are the NEW symmetric +//! actor-mailbox seam (replacing 002's transport-leaking `BackendAdapter` over +//! the strangler migration, §8). All field-level frozen — 02-impl may not +//! change shapes without re-approval (C.9). +//! +//! NOTE (strangler): the legacy `command::Command` (2-variant) + `adapter` +//! module still drive `session::run_turn`. This module is the new path; the +//! orchestrator selects it behind a feature flag until codex (P1) forces the +//! switch. + +use crate::event::PermissionKind; +use crate::state::SessionState; + +// ========================================================================== +// DOWNWARD — conversation → session (Commands) +// ========================================================================== + +/// The 10-variant command vocabulary (§C1, frozen). Capability-gated via +/// `Capabilities.supported_commands`; an unsupported command is rejected at +/// dispatch with `BackendError::CommandNotSupported`. +#[derive(Debug, Clone, PartialEq)] +pub enum Command { + /// Start / continue a turn. Multimodal `Vec` (NOT a String). + /// The adapter increments `turn_gen` on accept and echoes it in the receipt. + Send { + content: Vec, + metadata: CommandMeta, + }, + /// Scoped cancel. `Turn`/`Session` accepted by all; `Tool` only where + /// `supported_commands.cancel_tool`. + Cancel { target: CancelTarget }, + /// Inject content into the in-flight turn (adapter-private gated steering; + /// the b-side FSM never sees Steer). Gated by `supported_commands.steer`. + Steer { content: Vec }, + /// Answer a `Permission{request_id}` the backend raised. `decision` is the + /// coarse allow/deny (sound for every generic tool approval). + /// + /// `selected` carries the SPECIFIC chosen option for backends whose permission + /// is a single pick-one prompt: the ACP `optionId`, or a single-question claude + /// `AskUserQuestion` label. `None` = a plain allow/deny (the claude adapter then + /// falls back to the first option for an AskUserQuestion allow). Backends with a + /// pure accept/decline approval (codex/aionrs) ignore it. + /// + /// `answers` carries the FULL claude `AskUserQuestion` answer set — one entry + /// per question, multi-label for a `multiSelect:true` question (claude can ask + /// 1–4 questions in one call, §C1 / task #83). When non-empty the claude adapter + /// builds `updatedInput.answers` from THIS (every question keyed by its text, a + /// multi-select value as a JSON array claude joins with ", "); when empty it + /// degrades to the single-question `selected` path (back-compat). Captured wire: + /// `protocols/samples/claude-cli/2.1.178/ask_user_question_multi_*.ndjson`. + /// Non-claude backends ignore `answers`. + AnswerPermission { + request_id: String, + decision: PermissionDecision, + selected: Option, + answers: Vec, + }, + /// Answer a mid-session re-auth challenge (waiting_on_auth). Gated by + /// `supported_commands.answer_auth`. + AnswerAuth { + method_id: String, + credentials: serde_json::Value, + }, + /// User acknowledged a completed turn (done-unseen → seen). Always accepted; + /// folds at the conversation/fold-on-read layer, never the FSM. + Acknowledge { node_id: String }, + /// Switch mode. Confirmed non-optimistically via `ConfigChanged`. + SetMode { mode: String }, + /// Switch model. Confirmed via `ConfigChanged`. + SetModel { model: String }, + /// Set a generic agent config option (#99). `option_id` is the + /// backend-advertised option key (e.g. `effort`/`reasoning_effort`/`thought_level` + /// → claude `apply_flag_settings{effortLevel}`); `value` is the chosen value. + /// Distinct from SetMode/SetModel (which have dedicated wires); this is the + /// catch-all for advertised config options that lack a first-class command. + /// Confirmed via `ConfigChanged`. Backends with no such option reject it with + /// `CommandNotSupported`. + SetConfigOption { option_id: String, value: String }, + /// Rewind the backend's history by `num_turns` (logical turn count; the + /// adapter translates to each backend's native unit — codex + /// thread/rollback{numTurns} measured count-only; opencode message; hermes + /// checkpoint; claude `/rewind` is TUI/SDK-only → CommandNotSupported, §9.9). + /// Idle-only, blocking. Gated by `supported_commands.rewind`. + Rewind { num_turns: u32 }, + /// Enumerate named checkpoints/history points (O2). Capability-gated by + /// `supported_commands.list_checkpoints`; replies `SessionEvent::CheckpointList`. + ListCheckpoints, + /// Query the backend's cumulative session info (context-usage budget / session + /// cost). Capability-gated by `supported_commands.query_session_info`; replies + /// `SessionEvent::SessionInfo`. claude maps to `control_request{get_context_usage}` + /// / `{get_session_cost}`. A read-only query — never moves the FSM. + QuerySessionInfo { kind: SessionInfoKind }, +} + +/// Which cumulative session-info query [`Command::QuerySessionInfo`] requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionInfoKind { + /// Context-window budget (claude `get_context_usage`: totalTokens / maxTokens + /// + per-category breakdown). + ContextUsage, + /// Cumulative session cost (claude `get_session_cost`: a preformatted text + /// report). + SessionCost, +} + +/// Multimodal input block (§C1). An adapter that can't carry a variant rejects +/// the `Send` at dispatch; `Capabilities.prompt_blocks` advertises support +/// up-front so the UI never offers an image picker to a text-only backend. +#[derive(Debug, Clone, PartialEq)] +pub enum ContentBlock { + Text(String), + Image { data: Vec, media_type: String }, + Audio { data: Vec, media_type: String }, + ResourceLink { uri: String, mime_type: Option }, + AtMention { user_id: String }, +} + +/// Scoped cancel target (§C1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CancelTarget { + Tool(String), + Turn, + Session, +} + +/// Permission answer (§C1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PermissionDecision { + Approved, + Denied, + AllowAlways, +} + +/// One answered claude `AskUserQuestion` question (§C1 / task #83). `question` is +/// the exact question TEXT (claude's `answers` map is keyed by it — the input +/// schema guarantees question texts are unique). `labels` are the chosen option +/// labels: exactly one for a single-select question, one-or-more for a +/// `multiSelect:true` question. Carried on `Command::AnswerPermission.answers`; +/// the claude adapter emits `labels` as a JSON array, which claude's own zod +/// preprocess joins with ", " (live-captured 2.1.178). +/// `#[derive(Deserialize)]` so the conversation layer can parse the frontend's +/// `[{question, labels}]` answer payload (threaded as JSON through `ConfirmRequest` +/// → `ConvCommand::Confirm`) directly into this type at the session bridge. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct QuestionAnswer { + pub question: String, + pub labels: Vec, +} + +/// Read-only snapshot of ONE currently-open (unanswered) permission request, for +/// REST recovery (`GET /confirmations`) of a reloaded `waiting_confirmation` +/// conversation. Transient adapter-side state — NOT reducer/FSM/persisted. Carries +/// only the safe correlation + title surface (`request_id`/`tool_name`); the raw +/// tool `input` (command body / args) is deliberately NOT exposed (TIO-13). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingPermissionView { + /// The control correlation key (claude `request_id`); the recovery card's + /// id == call_id == this, matching the live `ConfirmationAdded` path. + pub request_id: String, + /// The tool the permission gates (used as the recovered card's title). May be + /// empty if the backend did not name it. + pub tool_name: String, + /// AskUserQuestion recovery: the question payload (`{questions:[…]}`) when + /// `tool_name == "AskUserQuestion"`, else `None`. Lets the REST `/confirmations` + /// recovery path rebuild a question card symmetric to the live `ConfirmationAdded` + /// frame (rather than degrading to an Allow/Deny permission card). Only + /// AskUserQuestion carries `input` from the adapter, so this is `None` for every + /// ordinary tool. + pub questions: Option, +} + +/// Per-command metadata (§C1). `client_msg_id` is the pending-queue correlation +/// key the adapter echoes on `PromptAccepted` (Addendum 3). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CommandMeta { + pub command_id: u64, + pub cwd: Option, + pub extra_args: Vec, + pub client_msg_id: Option, +} + +// ========================================================================== +// dispatch return + errors +// ========================================================================== + +/// Fast, synchronous-in-spirit dispatch receipt (§C1). `accepted` = the adapter +/// accepted+queued the command on the wire; it does NOT mean the turn finished +/// (the turn flows up `events()`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandReceipt { + pub accepted: bool, + pub admission: Admission, + pub turn_gen: u64, +} + +/// HOW a command was admitted (§C1 / §9.5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Admission { + /// In-flight now; `TurnStarted{turn_gen}` imminent. NO wall-clock turn + /// timeout (long turns are normal; liveness = StuckDetector dead-silence). + Started, + /// Accepted but not yet running (backend busy). Waits until the current turn + /// → Idle; NO admission timeout (§9.5 ⭐, O4). + Queued, + /// Steer/Acknowledge/AnswerPermission folded into the live turn (no new + /// turn_gen, no TurnStarted). + NoTurn, +} + +/// Seam errors (§C1). `CommandNotSupported.command` is a `&'static str`. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum BackendError { + #[error("command not supported by this backend: {command}")] + CommandNotSupported { command: &'static str }, + #[error("backend transport error: {0}")] + Transport(String), + #[error("session not found: {0}")] + SessionNotFound(String), + #[error("backend setup/handshake rejected: {0}")] + SetupRejected(String), + /// The backend process is still completing its startup handshake (e.g. codex's + /// `thread/started` has not arrived within the bound-thread window — common on a + /// cold start or an untrusted project that slows codex init). This is RETRYABLE: + /// the agent is coming up, not broken. Mapped to a user-readable "agent starting, + /// please retry" (503-class) instead of an opaque 500 (bug-hunt codex-500). + #[error("backend handshake timed out (agent still starting): {0}")] + HandshakeTimeout(String), +} + +// ========================================================================== +// UPWARD wrapper + state snapshot +// ========================================================================== + +/// Routing wrapper (Addendum 1 / §C1): a connection multiplexes many sessions; +/// downstream demuxes by `session_id`. The backend's transport key (codex +/// threadId / hermes sessionId / opencode sessionId) NEVER appears here — the +/// adapter maps it to the canonical logical `session_id` (§4.1 two-id). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SessionEnvelope { + pub session_id: String, + pub turn_gen: u64, + pub event: crate::event::SessionEvent, +} + +/// Full state snapshot (Addendum 8 / §9.12 / §C7) — pushed on EVERY state change +/// (NOT incremental). Carries the complete FSM + the pre-derived `can_send` so a +/// reconnecting / late subscriber gets complete truth in its first message. +/// Replaces the incremental `RoutedTransition` model. +#[derive(Debug, Clone, PartialEq)] +pub struct StateSnapshot { + pub session_id: String, + pub state: SessionState, + /// Pre-derived unlock (orchestrator calls `can_send_message`); conversation + /// reads this field, NEVER recomputes. + pub can_send: bool, + /// Pre-derived UI activity/spinner signal, orthogonal to `can_send` (§1.6 + /// session-surface contract). `= has_foreground_activity(state) || + /// background_active`. Frontend renders the spinner from THIS; `can_send` + /// gates the input box. The two combine into the two activity semantics + /// (`has_activity ∧ !can_send` = busy foreground; `has_activity ∧ can_send` = + /// detached background). The backend half is currently a stub (always false). + pub has_activity: bool, + /// Pre-derived (009 R2/§1): can the user proactively queue a next-turn message + /// while a turn is in flight? `= can_queue_message(state, caps.accepts_proactive_input)`. + /// The input box enables on `can_send || can_queue`; conversation reads this, + /// never recomputes. Capability-gated: true only on backends with a proactive + /// input path (claude stdin FIFO); false (degrades to can_send) elsewhere. + pub can_queue: bool, + /// Pre-derived (009 R2/§1): can the user cancel the in-flight turn right now? + /// `= can_cancel(state)` (Starting||Running, incl. requires_action). Esc maps to + /// Cancel when true, no-op otherwise. False in Idle/Error so a dead session never + /// fires a phantom interrupt. + pub can_cancel: bool, + pub turn_gen: u64, + pub last_reason: Option, +} + +/// Why the last transition happened (§C2/O5, StateSnapshot.last_reason). Typed +/// (agents emit rich reasons — claude `terminal_reason`, codex `TurnStatus`, +/// hermes `StopReason`); REUSES the existing typed families rather than being a +/// new load-bearing type. Orchestration-derived; reducer never reads it. +#[derive(Debug, Clone, PartialEq)] +pub enum TransitionReason { + Started, + Completed(crate::event::StopReason), + Errored(crate::state::ErrorReason), + Cancelled(crate::event::CancelReason), + PermissionRequested(PermissionKind), + PermissionResolved, +} + +// ========================================================================== +// session identity (two-id model, §4.1 / Addendum 4) +// ========================================================================== + +/// How a session is (re)opened. `session_id` is the STABLE AionCore-minted +/// logical id (immutable across fork/resume/crash-respawn; the demux key). +/// `backend_session_id` is the MUTABLE transport binding (adapter-private, +/// lives only on the checkpoint; None = backend session lost → adapter mints a +/// fresh one and re-registers the same logical id). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionSpec { + Fresh { + session_id: String, + }, + Resume { + session_id: String, + backend_session_id: Option, + }, +} + +// ========================================================================== +// Tier-2 persistence checkpoint (§C4 / §7.3, ⭐ COLLAPSED per conversation C §17, +// user-decided 2026-06-11 "1A"). The persisted Tier-2 state is a SINGLE column +// (`conversations.backend_session_id`); every FSM mid-turn substate +// (turn_boundaries / last_outcome / outstanding_permissions / waiting_on_auth / +// process_attached / last_subagents / workflow_tail) is turn-INTERNAL transient, +// NOT persisted — a crash loses it (accepted). So cold recovery is always `Idle` +// (the resume anchor + turn_gen continuity do NOT change the rebuilt phase). +// ========================================================================== + +/// 007 §C4 (collapsed): the minimal cold-start rebuild input. The persisted state +/// is just the resume anchor (`backend_session_id`) + `turn_gen` for epoch +/// continuity (so a resumed session's new turns don't reuse old epochs / collide +/// with the persisted block-stream grouping). All serde-derived. +/// +/// NOTE: the former rich fields are gone. The FSM never needs them at cold start — +/// recovery is `Idle` (C §17). If a future feature needs mid-turn-state recovery +/// (e.g. restore a pending-permission badge across a crash) or workflow disk-tail +/// resume, that is a NEW additive design, not a regression here. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Tier2Checkpoint { + /// Stable logical id (demux key, §4.1). + pub session_id: String, + /// The resume anchor: the backend's session id (codex threadId / claude on-disk + /// uuid). Persisted as `conversations.backend_session_id` (C §17). The CALLER + /// reads it to build `SessionSpec::Resume`; `rehydrate` does not consult it for + /// the rebuilt phase. None = backend session lost → resume rebinds a fresh one. + pub backend_session_id: Option, + /// Epoch continuity: the next turn resumes at `turn_gen + 1` so epochs stay + /// monotonic across a restart (and don't collide with the persisted block + /// stream's `turn_gen` grouping). Recoverable as `MAX(turn_gen)` over blocks. + pub turn_gen: u64, +} + +/// Map a `Command` to the `&'static str` used in `CommandNotSupported` (so +/// gating code has one canonical name per variant). +pub fn command_name(cmd: &Command) -> &'static str { + match cmd { + Command::Send { .. } => "send", + Command::Cancel { .. } => "cancel", + Command::Steer { .. } => "steer", + Command::AnswerPermission { .. } => "answer_permission", + Command::AnswerAuth { .. } => "answer_auth", + Command::Acknowledge { .. } => "acknowledge", + Command::SetMode { .. } => "set_mode", + Command::SetModel { .. } => "set_model", + Command::SetConfigOption { .. } => "set_config_option", + Command::Rewind { .. } => "rewind", + Command::ListCheckpoints => "list_checkpoints", + Command::QuerySessionInfo { .. } => "query_session_info", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_envelope_round_trips() { + let env = SessionEnvelope { + session_id: "logical-1".into(), + turn_gen: 3, + event: crate::event::SessionEvent::PromptAccepted { + client_msg_id: "m1".into(), + }, + }; + let json = serde_json::to_string(&env).unwrap(); + let back: SessionEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(env, back); + } + + #[test] + fn tier2_checkpoint_round_trips() { + // §C4 verification (collapsed, C §17): the minimal anchor + epoch skeleton + // serializes losslessly. + let ck = Tier2Checkpoint { + session_id: "logical-1".into(), + backend_session_id: Some("thread-abc".into()), + turn_gen: 5, + }; + let json = serde_json::to_string(&ck).unwrap(); + let back: Tier2Checkpoint = serde_json::from_str(&json).unwrap(); + assert_eq!(ck, back); + } + + #[test] + fn command_names_are_stable() { + assert_eq!(command_name(&Command::Rewind { num_turns: 2 }), "rewind"); + assert_eq!(command_name(&Command::ListCheckpoints), "list_checkpoints"); + } +} diff --git a/crates/aionui-session/src/capability.rs b/crates/aionui-session/src/capability.rs new file mode 100644 index 000000000..60947d5d0 --- /dev/null +++ b/crates/aionui-session/src/capability.rs @@ -0,0 +1,214 @@ +//! `Capabilities` — a backend's declared capability (BackendAdapter +//! responsibility 3). RFC §8 three tiers + the signal set it can emit. Read by +//! the adapter only — NEVER by the pure reducer `step()` (I12). + +/// Backend capability declaration (responsibility 3). RFC §8. +/// +/// 007 §C6: extended from 2 → 10 fields. ALL new fields are additive with +/// `Default`; the reducer NEVER reads `Capabilities` (I12), so this is a pure +/// a-side/UI surface. The split is "fixed enums (compile-time) vs runtime +/// discovery (handshake-filled open sets)" — these fields are the DISCOVERY +/// half: which Commands a backend accepts, which input blocks, which models/ +/// modes it advertises, which auth methods (§0.5 panel). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Capabilities { + /// Parse tier: Parsed (full-field, e.g. claude) / Hook (lifecycle hooks + /// only) / Opaque (pure black box). + pub tier: CapabilityTier, + /// Which canonical signals this backend can emit (missing heartbeat ⇒ + /// janitor degrades to plain idle-timeout). + pub emits: SignalSet, + /// NEW (007 §5.2/§C6): which `Command`s this backend accepts. UI gates + /// affordances up-front; dispatch rejects unsupported with CommandNotSupported. + pub supported_commands: CommandSet, + /// NEW (007 §5.3): which `ContentBlock` kinds `Send` accepts (text-only + /// backend never offered an image picker). + pub prompt_blocks: BlockSet, + /// NEW (007 §1.2/Addendum 3): whether this backend emits a real wire + /// PromptAccepted, the adapter synthesizes one, or none. + pub prompt_accepted: PromptAcceptedSource, + /// NEW (007 §9.10/Addendum 7): models this backend advertises (handshake- + /// filled; UI renders the switcher). Open set, not a fixed enum. + pub available_models: Vec, + /// NEW (007 §9.10): modes this backend advertises. Open set, discovery-filled — + /// codex (feature 012) carries the permission-profile ids `permissionProfile/list` + /// advertises. The three BUILT-IN tiers are surfaced as the LEGACY bare tokens the old + /// ACP path used (`:read-only`→`read-only`, `:workspace`→`auto`, `:danger-full-access` + /// →`full-access`) so the frontend receives byte-identical values and needs zero change + /// (feature 012 "Plan B"); a user-custom `[permissions.]` profile has no bare form + /// and keeps its colon id. The colon↔bare pair `profile_id_to_legacy_value`↔ + /// `normalize_to_profile_id` is the sole translation, mirroring legacy ACP's + /// `apply_advertised_modes` + `normalize_requested_mode`. codex has no separately-exposed + /// collaborationMode selector; its mode axis IS the permission axis (see codex_conn + /// `fill_discovery` Permissions arm). + pub available_modes: Vec, + /// NEW (007 §9.10): currently-selected model (persisted with the Node; + /// preloaded on resume). + pub current_model: Option, + /// NEW (007 §9.10): currently-selected mode. + pub current_mode: Option, + /// CP-1: currently-selected reasoning effort (thinking strength), when the + /// backend has an effort axis (claude `effortLevel`, per-model + /// `supportedEffortLevels`). UNLIKE `current_model`/`current_mode` (which the + /// backend reports back), effort is NOT echoed by claude — the backend remembers + /// the last value it set via `SetConfigOption{effort}` and surfaces it here so the + /// picker highlights the active level. `None` = no effort axis / never set. + pub current_effort: Option, + /// NEW (007 §9.17/Addendum 9): auth methods. NON-EMPTY ⇒ this backend can + /// raise a mid-session `Permission{kind:Auth}` and accept `AnswerAuth` → + /// enables the `waiting_on_auth` counter path. Empty = no mid-session auth. + pub auth_methods: Vec, + /// NEW (009 R2/§1): whether this backend accepts a PROACTIVE next-turn input + /// while a turn is in flight — the capability `can_queue` gates on. TRUE only + /// when the conversation layer can hand the backend a queued message that it + /// will run as the next turn (claude's persistent stdin FIFO: a write is + /// buffered and consumed as the following turn). FALSE for backends that have + /// no such proactive-input path from the conversation layer. + /// + /// ⚠️ This is DELIBERATELY NOT `supported_commands.steer`. Steer is a + /// mid-turn soft-injection wire (codex advertises `steer:true`) but the + /// conversation layer does not route Steer today, so keying `can_queue` off + /// `steer` would light the queue affordance for codex while nothing dispatches + /// it — a dead button (MX-QUEUE-3). `can_queue` MUST read this bit, never + /// `caps.steer`. Default false; only claude sets it true. + pub accepts_proactive_input: bool, + /// NEW (#101): user-invokable slash commands the backend advertises (claude + /// `control_request{initialize}` response `commands[]`; ACP `session/update` + /// `available_commands_update`; incl. MCP/plugin/skill-derived). Open set, + /// discovery-filled, additive (Default empty). The reducer NEVER reads it (I12) + /// — pure a-side/UI surface, projected by the REST get_slash_commands read. + pub slash_commands: Vec, +} + +/// #101: an advertised slash command (discovery, open set). `description` is the +/// human-readable summary; claude/ACP both carry it (claude also has an argument +/// hint we drop — the conversation layer's `SlashCommandItem` has no field for it). +/// serde-derived so `SessionEvent::CatalogUpdated` can carry it across the seam. +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +pub struct SlashCommandInfo { + pub name: String, + pub description: Option, +} + +/// 007 §C6: which Commands a backend accepts (capability gating). All-false +/// default = a minimal backend (Send/Cancel are assumed by the orchestrator). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CommandSet { + pub steer: bool, + pub cancel_tool: bool, + pub answer_permission: bool, + pub answer_auth: bool, + pub acknowledge: bool, + pub set_mode: bool, + pub set_model: bool, + pub rewind: bool, + pub list_checkpoints: bool, + /// Query the backend's cumulative session info (context-usage budget + + /// session cost). claude exposes `control_request{get_context_usage}` + + /// `{get_session_cost}` (live-confirmed 2.1.186); other backends have no such + /// query wire today → false. Replies `SessionEvent::SessionInfo`. + pub query_session_info: bool, +} + +/// 007 §C6: which `ContentBlock` kinds `Send` accepts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct BlockSet { + pub text: bool, + pub image: bool, + pub audio: bool, + pub resource: bool, + pub at_mention: bool, +} + +impl BlockSet { + /// Whether this `Send` content block is advertised by the backend. The + /// dispatch path calls this BEFORE wire-write so an un-advertised block is + /// rejected with `CommandNotSupported` — the design's Layer-2 rule ("adapter + /// authoritatively rejects → CommandNotSupported, never a silent drop", §C6). Mirrors the per-kind bool + /// against the canonical `ContentBlock` variant. + pub fn allows(&self, block: &crate::ContentBlock) -> bool { + use crate::ContentBlock; + match block { + ContentBlock::Text(_) => self.text, + ContentBlock::Image { .. } => self.image, + ContentBlock::Audio { .. } => self.audio, + ContentBlock::ResourceLink { .. } => self.resource, + ContentBlock::AtMention { .. } => self.at_mention, + } + } +} + +/// The stable `content_block:` name a `Send` rejection reports in +/// `CommandNotSupported{command}` for an un-advertised block (§C6). Kept next to +/// `BlockSet` so the name set and the bool set never drift. +pub fn block_kind_name(block: &crate::ContentBlock) -> &'static str { + use crate::ContentBlock; + match block { + ContentBlock::Text(_) => "content_block:text", + ContentBlock::Image { .. } => "content_block:image", + ContentBlock::Audio { .. } => "content_block:audio", + ContentBlock::ResourceLink { .. } => "content_block:resource", + ContentBlock::AtMention { .. } => "content_block:at_mention", + } +} + +/// 007 §1.2: source of the PromptAccepted confirmation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PromptAcceptedSource { + /// Real wire signal (codex turn/started, opencode POST-ack, hermes prompt-accepted). + Native, + /// Adapter fabricates it from a transport milestone (claude stdin flush-ok). + Synthesized, + /// Unavailable; pending queue falls back to the optimistic TurnStarted (degraded). + #[default] + None, +} + +/// 007 §9.10: an advertised model (discovery, open set). +/// serde-derived so `SessionEvent::CatalogUpdated` can carry it across the seam. +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +pub struct ModelInfo { + pub id: String, + pub name: String, + pub description: Option, + pub reasoning_efforts: Vec, +} + +/// 007 §9.10: an advertised mode (discovery, open set). +/// serde-derived so `SessionEvent::CatalogUpdated` can carry it across the seam. +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +pub struct ModeInfo { + pub id: String, + pub name: String, + pub description: Option, +} + +/// RFC §8 tier names (prose uses uppercase PARSED/HOOK/OPAQUE) map 1:1 to these +/// CamelCase variants (minor#3 alignment). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CapabilityTier { + #[default] + Parsed, + Hook, + Opaque, +} + +/// The canonical signals a backend can emit. Three-field kernel behavior is +/// graded in D11 (heartbeat = P0-verified with teeth; the others = forward- +/// compatible declarations). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SignalSet { + /// false ⇒ backend emits no `Heartbeat` liveness signal (no deadline to + /// idle-timeout (RFC §8). [P0-verified, T13 teeth] + pub heartbeat: bool, + /// Whether the backend parses tool lifecycle. [does NOT enter the reducer]: + /// a backend without it simply never emits ToolCall/ToolResult, and + /// OUTPUT-PRESENCE stays purely event-driven (FIX 6a/D11). Forward-compat + /// declaration, no kernel degradation branch. + pub tool_lifecycle: bool, + /// Whether there is an explicit terminal result. false ⇒ the adapter + /// synthesizes a `TurnResult` at EOF (D11/FIX 6b), so `crash_outcome` is + /// unchanged. P0's only backend (claude) = true (always emits result); + /// the false branch has no fixture (recorded as a 04 residual). + pub terminal_result: bool, +} diff --git a/crates/aionui-session/src/error.rs b/crates/aionui-session/src/error.rs new file mode 100644 index 000000000..9048f86e8 --- /dev/null +++ b/crates/aionui-session/src/error.rs @@ -0,0 +1,22 @@ +//! `SessionError` — crate-owned error (AGENTS.md: Domain crates use crate-owned +//! errors, never a new `AppError`). Distinct from the in-state terminal +//! `ErrorReason` (that models a session that REACHED an error state); this is +//! for fallible operations (spawn, parse-setup) surfaced to the caller. + +use aionui_process::ProcessError; + +/// Errors from session-control operations (spawning, transport setup). +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + /// Failed to spawn or wire the backend process (wraps the 001 layer). + #[error("process error: {0}")] + Process(#[from] ProcessError), + + /// The agent process's stdio was already taken (take_stdio is once-only). + #[error("stdio already taken for this turn")] + StdioAlreadyTaken, + + /// Internal invariant violation (should not happen; surfaced not panicked). + #[error("internal: {0}")] + Internal(String), +} diff --git a/crates/aionui-session/src/event.rs b/crates/aionui-session/src/event.rs new file mode 100644 index 000000000..b7505a753 --- /dev/null +++ b/crates/aionui-session/src/event.rs @@ -0,0 +1,1628 @@ +//! `SessionEvent` — the canonical, backend-agnostic event vocabulary (§C 6.2, +//! frozen). The `BackendAdapter` framing+parse stage emits these; the +//! monomorphic reducer consumes ONLY these. Orchestration also lowers +//! `Command::Send` into this enum so `step()` has a single entry point. +//! +//! Invariant I8: NO variant name / field name / field value ever contains a +//! claude-specific literal token (api_retry / subtype / compact_boundary / …) — +//! all normalized at the adapter. + +/// Canonical session event. backend-agnostic. Every variant traces to a +/// measured fixture or is an explicit mock/lower input. +/// +/// FIX 4a (derive choice, the TRUE reason): derives `PartialEq` only, NOT `Eq`. +/// This is NOT because `serde_json::Value` cannot be `Eq` — it actually can +/// (serde_json rejects NaN/Inf at construction, so its float is always finite; +/// `impl Eq for Number` / `impl Eq for Map` hold). The real reason: the +/// `AdapterSpecific.payload` is an opaque escape hatch this contract does NOT +/// promise total equality over; `assert_eq!` only needs `PartialEq`. Deriving +/// fewer traits than available is always legal ⇒ the shape compiles as-is. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] // +Serialize/Deserialize (Addendum 2a, 007 §C2). only PartialEq — see FIX 4a above +pub enum SessionEvent { + // ---- command lower (FIX 2): orchestration lowers Command::Send here ---- + /// Turn starter (C3/D1). Carries this turn's epoch (current_epoch, already + /// +1 at lower time, I3/D9). The ONLY Running trigger; on receipt the + /// reducer goes Idle/Starting → Running{ since_epoch: epoch, .. all + /// false/empty }. NEVER keyed on system/init or the first assistant. + TurnStarted { epoch: u64 }, + + /// User-initiated cancel (feature 004 S14/R14). Orchestration lowers + /// `Command::Cancel` here. Folds into the EXISTING `Idle` terminal + /// (behavior-preserving, mirrors old ACP `cancel()→emit_finish(None)`), + /// NEVER into `Error{Crashed}`. CRITICAL (crash≠cancel): cancel-then-kill makes + /// the process exit, but the FSM is already terminal (Idle) by the time the + /// resulting `Detached` arrives, so I10 absorbs it — a cancel never + /// mislabels as a crash. MUST NOT be lowered as `Detached`. + Cancel, + + // (No clock/timeout lower: AionCore does NOT auto-time-out a turn. A user may + // leave a permission unanswered indefinitely; a genuinely wedged agent is + // ended by user Cancel, and a dead process by `Detached`. The former deadline + // janitor + `Timeout` event were removed 2026-06-11, user-decided — no + // system-imposed deadline on any state.) + + // ---- data events (adapter framing+parse output) ---- + /// assistant content block type="text". Non-empty = substantive (C5). + MessageDelta { item_id: String, text: String }, + /// assistant content block type="thinking". NOT substantive (C5). + ThoughtDelta { item_id: String, text: String }, + /// assistant content block type="tool_use". tool_use_id demux key is + /// reliable (measured). `subagent` (007 §5.6/§9.14) distinguishes an inline + /// delegate / a spawned child session / a claude Workflow node — consumed + /// ONLY by the RFC §9 promote layer + UI display-kind, NEVER by `step()` + /// (which only checks "is this a tool_use → mark substantive"). Default + /// `Inline` keeps existing single-agent behavior unchanged. + /// + /// Gap #4 / H2: `input` carries the tool's ARGUMENTS JSON (claude tool_use + /// `input`, ACP tool_call `rawInput`, codex command/tool item fields) the + /// parser used to drop — so the downstream `ConvDomainEvent::ToolStep.input` + /// (WS step 1) is no longer always empty. `serde_json::Value` is the + /// backend-neutral carrier; `Value::Null` = no/unknown args. The pure + /// `step()` reducer NEVER reads it (mirrors the ToolResult-informational law); + /// it is consumed only by the conversation `TurnFinalizer`. `#[serde(default)]` + /// so older persisted Tier-1 frames (no `input` key) deserialize to + /// `Value::Null` — same additive discipline as `subagent` / `ToolResult.content` + /// / `TurnResult.outcome`. ⚠️ TIO-13: this slot carries Bash/Write bodies — no + /// info-level log may print it. + ToolCall { + tool_use_id: String, + name: String, + #[serde(default)] + subagent: SubagentKind, + #[serde(default)] + input: serde_json::Value, + /// 009 H5: the `tool_use_id` of the SUBAGENT whose turn produced this call, + /// when claude attributes the top-level frame to one (frame `parent_tool_use_id`). + /// `None` = the main agent. The pure `step()` reducer NEVER reads it (attribution + /// is a display concern); it is consumed only by the conversation `TurnFinalizer` + /// to hang the tool step under the right subagent node (`ConvDomainEvent::ToolStep + /// .parent_tool_use_id`). `#[serde(default)]` so older persisted frames (no key) + /// deserialize to `None` — same additive discipline as `subagent` / `input`. + #[serde(default)] + parent_tool_use_id: Option, + }, + /// user content block type="tool_result", referring back by tool_use_id. + /// A completed tool_use = substantive output (C5). + /// 009 R7 / §12.9 H3: carry `is_error` so a FAILED tool is not rendered as a + /// success — the wire `tool_result` block has it (claude/codex), and dropping + /// it was the bug where a red tool showed green. `#[serde(default)]` → false + /// (success) so a pre-R7 persisted ToolResult frame (no key) still deserializes — + /// same additive discipline as `content` / `parent_tool_use_id` below. + /// + /// 009 R8: `content` carries the backend-normalized tool OUTPUT the parser used + /// to drop (text / produced file paths / inline images — e.g. a claude Read-tool + /// image, a codex command's stdout, an ACP diff). The pure `step()` reducer + /// NEVER reads it (the ToolResult-informational law holds, reducer.rs); it is + /// consumed only by the conversation `TurnFinalizer` → `ToolResultDisplay`. + /// `#[serde(default)]` so older persisted Tier-1 frames (no key) deserialize to + /// an empty Vec — same additive discipline as `TurnResult.outcome` / `ToolCall.subagent`. + ToolResult { + tool_use_id: String, + #[serde(default)] + is_error: bool, + #[serde(default)] + content: Vec, + /// 009 H5: the `tool_use_id` of the SUBAGENT whose turn produced this result + /// (frame `parent_tool_use_id`); `None` = the main agent. Same role + additive + /// discipline as `ToolCall.parent_tool_use_id` — consumed only by the + /// conversation `TurnFinalizer` for subagent attribution, never by `step()`. + #[serde(default)] + parent_tool_use_id: Option, + }, + /// backend-neutral "alive, no progress" heartbeat. claude sources (normalized + /// two ways by ClaudeAdapter): system/api_retry (network backoff) + compaction + /// (compact_boundary / SDK compacting, the no-chunk window). Since the deadline + /// janitor was removed (no auto-timeout), this is now a pure reducer no-op kept + /// as an HONEST liveness/diagnostic signal — a future liveness consumer (e.g. a + /// UI "still working" hint) can read it without re-introducing a deadline. + Heartbeat, + /// Terminal result. Routing is by `is_error` (C7/R10), NEVER by subtype + /// (measured: error turns still carry subtype="success"). + TurnResult { + /// Normalized error bit (claude source: result.is_error). The ONLY + /// error-routing basis. + is_error: bool, + /// Normalized HTTP status (claude source: result.api_error_status, + /// non-null only on error turns). typed Option = backend-neutral + /// diagnostic, not a claude token. + api_error_status: Option, + /// Normalized turn text (claude source: result.result; empty string on + /// success = empty-turn signal; non-empty on error). + result_text: String, + /// Turn identity (the `TurnStarted{epoch}` this result belongs to). The + /// persistent process breaks the reducer's D9 "turn == process boundary" + /// assumption: a CANCELLED turn's trailing `result` (claude flushes one + /// ~2.6s after an interrupt) can arrive DURING the next turn's `Running`. + /// The reducer drops such a stale result (`epoch < Running.since_epoch`), + /// mirroring the `TurnStarted` epoch guard — so a cross-turn leak can + /// never mislabel the new turn as `Error`. The 002 adapter is + /// epoch-agnostic (frame parsing has no turn context) and emits `epoch: 0`; + /// the ai-agent reader stamps the live epoch when forwarding the b-side + /// event. A backend that never cancels mid-turn can leave it 0 (then every + /// result is `>= since_epoch=0` and settles normally). + epoch: u64, + /// Rich terminal outcome (007 §C2/O3, 2026-06-10). `is_error:bool` above + /// is the LOSSY routing bit the reducer reads; `outcome` is the typed + /// richness the adapter fills from each backend's terminal wire (claude + /// `stop_reason` / 12-value `terminal_reason`; codex `turn.status` + /// Completed|Interrupted|Failed; hermes ACP `StopReason`; opencode + /// `StepFinishPart.reason`). ADDITIVE: `step()` NEVER reads `outcome` (it + /// routes on `is_error` only); persisted Tier-2 `last_outcome` AND rides + /// this event. The UI truncation/refusal badge consumes it (deferred P4). `#[serde(default)]` + /// so older persisted frames deserialize as `Completed{EndTurn}`. + #[serde(default)] + outcome: TurnOutcome, + }, + /// Process-exit edge (from `AgentIo::wait_for_exit`). exit=None ⇒ exited + /// with unknown status (treat as terminal exit, D3). I11 drain-before-honor: + /// orchestration MUST drain+parse stdout (apply any pending TurnResult) + /// before stepping this event. + Detached { + exit: Option, + /// G2: a redacted, allowlisted one-line summary of the process's stderr + /// tail, captured AT THE BACKEND on a real EOF/exit. Lets the user see a + /// *reason* (e.g. "usage limit exceeded", "connection refused") instead + /// of a bare exit code, while never letting raw/untrusted stderr (which + /// can carry API keys) cross the backend boundary — only lines matching + /// the 14-keyword allowlist in `aionui_common::error_extract` survive, + /// capped at 240 chars. `None` when: nothing matched the allowlist, the + /// process never took stdio (startup double-take guard — unsafe to + /// attribute), or a backend that has no stderr. `#[serde(default)]` so + /// older persisted frames (no field) deserialize as `None`. + #[serde(default)] + redacted_summary: Option, + }, + /// Unknown message-type catch-all, never panics (S3/T3b). NO node_id + /// (single node). The reducer MUST treat this as opaque — count or ignore + /// only, MUST NOT match on tag/payload content (I8/I13). + AdapterSpecific { tag: String, payload: serde_json::Value }, + + /// LC-8a: a to-do / task PLAN snapshot (codex `turn/plan/updated`, ACP + /// `session/update{sessionUpdate:"plan"}`). FULL-REPLACE snapshot semantics — each + /// emission carries the entire current plan, the consumer replaces (never merges). + /// FSM-ORTHOGONAL: the reducer treats this as a no-op (plan is *content within a + /// Running turn*, not a state — cross-protocol verified); only the conversation + /// layer projects it to the UI to-do panel. The ACP `PlanEntry` is a superset of + /// codex `TurnPlanStep`, so this unified shape follows ACP: `priority`/`explanation` + /// are `Option` (codex has no priority; ACP has no explanation). + Plan { + entries: Vec, + explanation: Option, + }, + + // ---- interactive permission / control-request (R9; F3 real claude parse) ---- + /// A claude `control_request` (subtype `can_use_tool`) that needs a user + /// answer (F3): enters the requires-action sub-state (waiting_on_approval + /// +1). `request_id` is the control correlation key (echoed in the + /// control_response), NOT the tool_use_id. The reducer ref-counts on + /// `request_id` ONLY — it never inspects the tool / questions payload (that + /// rich detail rides the a-side card + the manager's pending map). In F1's + /// permission mode only AskUserQuestion routes here; routine tools auto-run. + /// `kind` (007 §9.17/§6b b3) distinguishes a tool/write approval from a + /// mid-session re-auth challenge so they ref-count into SEPARATE counters + /// (`waiting_on_approval` vs `waiting_on_auth`) and the UI can tell "approve + /// tool" from "please re-login". Default `Tool` keeps existing behavior. + Permission { + request_id: String, + #[serde(default)] + kind: PermissionKind, + /// G3: backend-parsed, NON-authoritative context the conversation layer + /// uses to DECIDE auto-approval (e.g. team-MCP allowlist) WITHOUT the + /// backend deciding anything. Shape (when present, ACP only): + /// `{ "server_name": "aionui-team"?, "options": [{"option_id","kind"}] }`. + /// The backend only PARSES the `session/request_permission` params into + /// this; the conversation facade consults an injected `PermissionAuthorizer` + /// and, if it auto-approves, dispatches `AnswerPermission` instead of + /// surfacing a card. The reducer NEVER reads it (ref-counts on request_id + /// only — §R9). `None` for backends/paths that carry no MCP context + /// (claude direct-CLI raises permission only for AskUserQuestion). + /// `#[serde(default)]` so older persisted frames deserialize as `None`. + /// ⚠️ TIO-13: may carry a tool title — never log at info level. + #[serde(default)] + metadata: Option, + /// AskUserQuestion projection: the raised tool's name, so the conversation + /// layer can recognize an `AskUserQuestion` permission and render it as a + /// question card (not a generic allow/deny). `None` for every other path + /// (ordinary tool approvals, ACP MCP, auth). The reducer NEVER reads it + /// (ref-counts on request_id only — §R9). `#[serde(default)]` so older + /// persisted frames deserialize as `None`. + #[serde(default)] + tool_name: Option, + /// AskUserQuestion projection: the raised tool's raw `input` + /// (`{questions:[{question, header, options:[{label,description}], multiSelect}]}`). + /// This is QUESTION CONTENT meant to be shown to the user — NOT a sensitive + /// tool body, so TIO-13's redaction does not apply (`detail` still stays + /// `None` for ordinary tools). Carried so the question text/options reach the + /// frontend, which cannot synthesize text that exists on no transport. `None` + /// for non-AskUserQuestion paths. Reducer no-op. `#[serde(default)]` for + /// back-compat. + #[serde(default)] + input: Option, + }, + /// The matching resolve (symmetric with `Permission`): decrements the SAME + /// counter the originating `Permission` incremented (Tool→waiting_on_approval, + /// Auth→waiting_on_auth); only the whole set reaching zero leaves the + /// requires-action sub-state back to plain Running (I7). Emitted when the user + /// answers (manager) OR when claude retracts via `control_cancel_request`. + /// `kind` (007 §6b b3) lets the PURE reducer pick the right counter without an + /// external request_id→kind map; the adapter echoes the originating kind. + /// Default `Tool` preserves existing behavior. + PermissionResolved { + request_id: String, + #[serde(default)] + kind: PermissionKind, + }, + + // ====================================================================== + // ADDITIVE backend-produced / orchestration variants (007 §C2 / §9.0). + // The reducer takes explicit no-op arms for ALL of these EXCEPT SubagentUpdate + // (the ONE §6b b1 exception it READS). Adding them is the mechanical Rust + // exhaustiveness requirement, NOT a decision-logic change (§6a). + // ====================================================================== + /// Backend-CONFIRMED prompt handoff (Addendum 3 / U14) — DISTINCT from the + /// optimistic, locally-lowered `TurnStarted`. Carries the correlation key the + /// conversation pending-queue needs to drop exactly the admitted message. + /// codex `turn/started` (Native) / claude stdin-flush-ok (Synthesized) / + /// hermes prompt-result (Native) / opencode POST-ack (Native). Reducer no-op. + PromptAccepted { client_msg_id: String }, + + /// Per-turn typed usage/cost (Addendum 5 / U15). Adapter normalizes a + /// cumulative wire counter to a per-turn DELTA (G6). Reducer no-op (pure + /// consumer signal). codex `thread/tokenUsage/updated.last`; claude + /// `result.usage`; cost_usd may be None. + UsageDelta { + input_tokens: u64, + output_tokens: u64, + total_tokens: u64, + cost_usd: Option, + }, + + /// MCP / tool provisioning as a LIVE event (Addendum 5 / U16). Reducer no-op. + Provisioning { phase: ProvisioningPhase }, + + /// Backend receipt of a `Command::Rewind` (Addendum 6 / U24). `to_turn` = + /// post-rollback history-end turn_gen. Reducer no-op; TRIGGERS the + /// orchestrator's local rehydrate (drop Tier-1/2 after `to_turn`, §9.9). + Rewound { to_turn: u64 }, + + /// Non-optimistic mode/model switch confirm (Addendum 7 / U24). Reducer + /// no-op (config is orthogonal). Closes reverse-coverage B8/B9/C3: adapters + /// emit THIS instead of `AdapterSpecific{tag:"config"}`. + ConfigChanged { + mode: Option, + model: Option, + }, + + /// The selectable model/mode/slash-command catalog became available. Unlike + /// ACP (whose `session/new` RESPONSE carries the catalog synchronously, so the + /// frontend picker is populated the instant `open_session` returns), the direct- + /// CLI backends learn their catalog ASYNCHRONOUSLY, well after `open_session`: + /// claude from the `control_request{initialize}` RESPONSE (~6s post-open on + /// Bedrock), codex from the `model/list` / `collaborationMode/list` RESPONSEs. + /// Before this event existed the adapters merely wrote the catalog into their + /// private `discovered` cache with NO upward signal, so the frontend — which had + /// already read an empty `config_options` on open — never re-fetched and the + /// model selector stayed permanently disabled. This event is the direct-CLI + /// analogue of the ACP path's `emit_snapshot_events` catalog push: the event-pump + /// translates it into an `AcpConfigOption` frame so the frontend's existing + /// `useAcpConfigOptions` re-fetch path fires. Reducer no-op (pure UI surface, like + /// `ConfigChanged`); non-persistent (a fresh open re-discovers it). The payload is + /// self-contained because the pump holds ONLY the event stream, never a backend + /// handle (leak-prevention), so it cannot call `capabilities()` to assemble it. + CatalogUpdated { + models: Vec, + /// The selectable mode catalog. For codex (feature 012) this carries the three + /// fixed permission-profile tiers (`default`/`full-access`/`read-only`) mapped + /// from `permissionProfile/list` — codex exposes NO separate collaborationMode + /// selector; its mode axis IS the permission axis. Other backends carry their + /// own mode semantics (claude permission-mode, etc.). + modes: Vec, + slash_commands: Vec, + }, + + /// ⭐ Subagent lifecycle normalization (Addendum 8 / §9.12-9.14 / U22). THE + /// ONE additive variant the reducer READS (§6b b1): `step()` upserts it into + /// `Running.subagents` (key=`r#ref`, last-write-wins). claude Task subagent + /// (from `parent_tool_use_id`/`task_*`); claude Workflow (adapter privately + /// tails on-disk journal — paths never cross the seam, §9.14); codex + /// collab-agent; opencode child-session. Flat-Vec + `parent_ref` edges + /// model multi-level (§9.13). + SubagentUpdate { + r#ref: String, + label: Option, + status: SubagentStatus, + parent_ref: Option, + }, + + /// 009 R6b / §3 / H1: RICH per-agent workflow detail for the background-plane + /// roster (claude `workflow_progress[].workflow_agent`). Distinct from + /// `SubagentUpdate` (which carries only the lifecycle status for the FSM + /// foreground plane): this carries the display fields (model / tokens / tools / + /// loop state / preview) that the per-agent panel renders. Reducer NO-OP (the + /// FSM never reads detail); the orchestrator folds it into `workflow_roster`. + /// Keyed by `ref` (= claude `agentId` once running, else `label`); `parent_ref` + /// = the container `task_id` (the 1:N workflow→agents relation). All rich + /// fields Option — only claude fills them; codex/ACP/aionrs never emit this. + SubagentDetail { + r#ref: String, + parent_ref: Option, + label: Option, + /// LLM-loop phase (start→progress→done), orthogonal to task outcome. + loop_state: Option, + model: Option, + tokens: Option, + tool_calls: Option, + last_tool_name: Option, + }, + + /// Out-of-turn diagnostic NOTICE (codex `warning` / `guardianWarning` / + /// `deprecationNotice` / `configWarning`). A user-facing message that is NOT a + /// turn terminal and NOT a tool/MCP provisioning phase — e.g. "this config key is + /// deprecated", a guardian (sandbox) advisory, a malformed-config warning. Before + /// this they fell into the catch-all `AdapterSpecific{codex_notif:*}` and never + /// reached the user. DISTINCT from `TurnResult{is_error}` (a turn outcome) and + /// `Provisioning` (MCP/tool lifecycle): a Notice is an advisory the UI surfaces as + /// a status/banner. The codex `error` notification is NOT mapped here — it carries + /// `{turnId, willRetry}` and is either a transient retry (→ Heartbeat) or the + /// turn's terminal cause (→ already covered by `turn/completed`). Reducer NO-OP + /// (an advisory does not move the FSM). Only the conversation layer projects it. + Notice { level: NoticeLevel, message: String }, + + /// Live tool-OUTPUT delta (codex `item/commandExecution/outputDelta`). The + /// incremental stdout/stderr of a RUNNING tool, keyed by the owning tool's + /// `item_id` (= the `ToolCall.tool_use_id` / codex `call_N`). PLAINTEXT (codex's + /// turn-scoped item stream is NOT base64 — verified live 0.139.0, + /// missing-wire-probe). DISTINCT from `MessageDelta` (assistant prose) and from + /// `ToolResult.content` (the AUTHORITATIVE full output at tool completion): this + /// is a pure liveness/UX stream so the UI can show a command's output AS IT RUNS. + /// Ephemeral (never persisted — the durable record is the completed `ToolResult`, + /// whose `aggregatedOutput` is the full text; a reconnect re-renders from there). + /// The pure `step()` reducer NEVER reads it (no FSM meaning — a tool streaming + /// output is just a Running turn making progress). Only the conversation layer + /// projects it to a live tool-output pane. Claude/ACP/aionrs never emit it today. + ToolOutputDelta { item_id: String, text: String }, + + /// Live cumulative turn DIFF (codex `turn/diff/updated`). The FULL git-style + /// unified diff of ALL file edits in the current turn so far (re-sent cumulatively + /// as edits land — full-replace snapshot, NOT a per-chunk delta; verified live + /// 0.139.0). DISTINCT from a `ToolResult` FilePath (the per-file authoritative + /// result at fileChange completion): this is the turn-level live diff-view feed. + /// Ephemeral (re-derivable; the durable per-file diffs ride completed ToolResults). + /// Reducer NO-OP (a diff updating is just a Running turn making progress). Only the + /// conversation layer projects it to a live diff panel. codex-only today. + TurnDiffUpdated { diff: String }, + + /// Streaming-item birth bracket (§9.2 / U17). Establishes who owns `item_id`. + /// Reducer no-op; the `TurnFinalizer` (conversation side) opens a PendingItem. + ItemStarted { item_id: String, kind: ItemKind }, + + /// Streaming-item end bracket (§9.2 / U18). `truncation` set if the item was + /// cut (max_tokens / context_window / wire-end). Reducer no-op. + ItemCompleted { + item_id: String, + truncation: Option, + }, + + /// The folded Tier-1 display record (§9.2 / U19) — the durable replacement + /// for ephemeral deltas. NOT a wire event; a `TurnFinalizer` fold PRODUCT. + /// Reducer no-op. + MessageFinalized(FinalizedMessage), + + /// Reconnect catch-up (G1 / §9.3 / U20): the orchestrator emits the CURRENT + /// in-flight state first on a re-subscribed stream. Orchestration-lowered + /// (NOT persisted). Reducer no-op. + Snapshot { state_repr: String, turn_gen: u64 }, + + /// Backpressure overflow signal (§9.4 / U21): a broadcast receiver fell + /// behind. Orchestration-lowered. Reducer no-op; conversation triggers a + /// reconnect() to refetch. + Lagged { skipped: u64 }, + + /// 009 R6 (cleanup path 3): the F-4 idle-reap suspended this session's backend + /// process. Orchestration-lowered (NOT persisted). Reducer NO-OP — suspend is + /// FSM-invisible by design (the wake re-spawns on the same event_tx). The ONE + /// orchestrator effect: CLEAR this session's workflow_roster. Otherwise a + /// workflow that was running when the process was reaped would never receive + /// its `task_notification` (the process is gone), leaving has_activity stuck + /// true forever — the §12.7 liveness leak this signal closes. + BackendSuspended, + + /// ListCheckpoints receipt (O2 / §1 / U-new). The adapter normalizes each + /// backend's checkpoint/history enumeration capability (hermes `list` / codex + /// `thread/turns/list` / opencode history endpoint). + /// Reducer no-op; UI integration (checkpoint selector) deferred. capability-gated. + CheckpointList { entries: Vec }, + + /// `QuerySessionInfo` receipt: the backend's cumulative session info, replied to + /// an on-demand query (claude `control_request{get_context_usage}` / + /// `{get_session_cost}`, live-confirmed 2.1.186). DISTINCT from the per-turn + /// `UsageDelta` (a streamed turn cost) — this is the WHOLE-SESSION snapshot the + /// user explicitly asks for (context-window fill %, cumulative cost). Exactly one + /// of the payload fields is set, matching the requested `SessionInfoKind`. + /// Reducer no-op (a query reply never moves the FSM); capability-gated by + /// `query_session_info`. Ephemeral (a fresh snapshot each query, not history). + SessionInfo { + /// Context-window budget (claude get_context_usage): `(used, max)` tokens. + /// `None` unless the query was `ContextUsage`. + context_usage: Option, + /// Cumulative cost report text (claude get_session_cost). `None` unless the + /// query was `SessionCost`. A preformatted multi-line string (the backend + /// owns the formatting — we do not parse it). + cost_text: Option, + }, + + /// Addendum 9 (consumer-driven, conversation Tier-2): the adapter lowers its + /// current `(session_id → backend_session_id)` binding downstream so the + /// conversation layer can persist `conversations.backend_session_id` as the + /// rewind/resume anchor (`--resume --fork-session`, codex `thread/resume`, + /// …). This is the SOLE channel by which `backend_session_id` escapes the + /// adapter (§4.1 Addendum 4's "transport key never escapes" — minimally, + /// explicitly broken for this ONE value); all other transport detail (bytes / + /// handles / JSON-RPC) still never leaves the adapter. Orchestration-lowered + /// (classify ⇒ OrchestrationLowered ⇒ NOT persisted as an append-only event; + /// it is a pass-through the conversation reads, not part of rewind replay). + /// Reducer no-op (never touches SessionState / can_send). Emitted whenever the + /// adapter's private binding row updates: open_session success, Resume + /// re-attach, fork, or backend-session loss. `None` = backend session lost / + /// not yet established. + BackendBound { backend_session_id: Option }, +} + +// ========================================================================== +// ADDITIVE supporting types (007 §C2 / §9). All serde-derived for Tier-1/2 +// persistence. None is read by `step()` except where wired through +// SubagentUpdate → Running.subagents (§6b b1). +// ========================================================================== + +/// 009 R8: one backend-normalized piece of a completed tool's OUTPUT (carried on +/// `SessionEvent::ToolResult.content`). Backend-neutral union the conversation +/// `TurnFinalizer` maps into its own `ToolResultDisplay`: +/// - `Text` — textual output (codex command stdout, ACP text content, a claude +/// text tool_result block). +/// - `FilePath` — a file the tool wrote/read/diffed, referenced by PATH (the +/// durable artifact anchor; codex fileChange/imageGeneration, ACP diff/locations, +/// a claude on-disk file). `old_text`/`new_text` carry a diff when the wire has one. +/// - `Image` — INLINE image bytes (the ONLY producer is a claude Read-tool +/// tool_result `image` block, source base64). Transient/in-flight: the bytes +/// must be spilled to disk before persistence (deferred — see TurnFinalizer); +/// NEVER persisted base64-inline (Tier-1 row / WS-frame bloat). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum ToolResultContent { + Text(String), + FilePath { + path: String, + #[serde(default)] + mime: Option, + #[serde(default)] + old_text: Option, + #[serde(default)] + new_text: Option, + }, + Image { + media_type: String, + data: Vec, + }, +} + +/// 007 §5.6/§9.14: how a `ToolCall` delegates. Default `Inline` (same session, +/// folds to ToolCall/ToolResult). `Spawned` = a real child `SessionBackend`. +/// `Workflow` = a claude Workflow node (pure marker, NO payload — internals +/// surface as ordinary child subagents the adapter mints by tailing disk; paths +/// never cross the seam, §9.14). Consumed by the RFC §9 promote layer + UI only. +#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)] +pub enum SubagentKind { + #[default] + Inline, + Spawned { + session_id: String, + }, + Workflow, +} + +/// 007 §9.17/§6b b3: a `Permission` request's class. Default `Tool` (existing +/// behavior). `Auth` = mid-session re-auth challenge → ref-counts into the +/// SEPARATE `waiting_on_auth` counter so the UI can distinguish it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +pub enum PermissionKind { + #[default] + Tool, + Auth, +} + +/// LC-8a: one entry in a [`SessionEvent::Plan`] to-do snapshot. Unified shape over +/// codex `TurnPlanStep{step,status}` (priority absent) and ACP `PlanEntry{content, +/// status,priority}`. The reducer never reads it (plan is FSM-orthogonal content); +/// only the conversation `TurnFinalizer` projects it to the UI to-do panel. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct PlanEntry { + pub content: String, + pub status: PlanStatus, + /// ACP-only (codex has no per-step priority). `None` for codex / when absent. + #[serde(default)] + pub priority: Option, +} + +/// LC-8a: plan-step status. Wire: codex camelCase `inProgress`, ACP snake_case +/// `in_progress` — both normalized HERE to the canonical `InProgress` (I8). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum PlanStatus { + Pending, + InProgress, + Completed, +} + +/// LC-8a: ACP plan-step priority. codex never sets one (the field is `Option`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum PlanPriority { + High, + Medium, + Low, +} + +/// 007 §9.12/§9.13: a subagent's lifecycle status (codex 7-state minus +/// NotFound). Carried by `SubagentUpdate` → upserted into `Running.subagents`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum SubagentStatus { + PendingInit, + Running, + Interrupted, + Completed, + Errored, + Shutdown, +} + +impl SubagentStatus { + /// True for the lifecycle-final states. The active complement + /// (`PendingInit | Running`) is what `has_foreground_activity` keys on, so + /// this is its exact dual — kept here as the single source of truth. + /// Feature 009 §11.4 terminal-absorption uses it to reject a late + /// non-terminal update that would otherwise resurrect a finished subagent. + pub fn is_terminal(self) -> bool { + matches!( + self, + SubagentStatus::Interrupted + | SubagentStatus::Completed + | SubagentStatus::Errored + | SubagentStatus::Shutdown + ) + } +} + +/// 007 §C2/O3: rich terminal outcome riding `TurnResult.outcome`. The reducer +/// NEVER reads this (routes on `is_error:bool`); it carries the {clean-done, +/// truncated, refused, cancelled, failed} distinction the one-bit projection +/// loses. Default `Completed{EndTurn}` so older frames deserialize cleanly. +#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)] +pub enum TurnOutcome { + Completed { + stop_reason: StopReason, + }, + Cancelled { + reason: CancelReason, + }, + Failed, + #[default] + EndTurn, +} + +/// 007 §9.5: why a `Completed` turn stopped. +#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)] +pub enum StopReason { + #[default] + EndTurn, + Truncated(TruncationKind), + Refused { + category: Option, + }, +} + +/// 007 §9.5: truncation cause (max_tokens / context / max_turns / budget / +/// wire-end-without-ItemCompleted). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum TruncationKind { + MaxTokens, + ContextWindow, + MaxTurns, + Budget, + Truncated, +} + +/// 007 §9.5: why a turn was cancelled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum CancelReason { + UserCancel, + BargeIn, + SocketAbort, + ToolDenyChain, +} + +/// 007 §9.6 (RFC §15③): MCP/tool provisioning phase. Connected-but-degraded is +/// distinct from connect/auth failure. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum ProvisioningPhase { + Injecting, + ToolsWaiting, + ToolsReady, + Degraded { reason: String }, + LoadFailed { reason: String }, +} + +/// Severity of an out-of-turn [`SessionEvent::Notice`]. `Warning` covers codex +/// `warning` / `guardianWarning` / `configWarning` (something the user should know +/// but the turn can still proceed); `Info` covers `deprecationNotice` (advisory, +/// non-urgent). Backend-neutral so a future backend's advisory maps here too. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum NoticeLevel { + Info, + Warning, +} + +/// 007 §9.2: kind of a streaming item being bracketed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ItemKind { + Text, + Thinking, + Tool, + Image, +} + +/// 007 §9.2: truncation info on `ItemCompleted`. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct TruncationInfo { + pub kind: TruncationKind, + pub partial_text: Option, +} + +/// 007 §9.2: the folded Tier-1 display record (the `MessageFinalized` payload). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FinalizedMessage { + pub item_id: String, + pub kind: ItemKind, + pub content: String, + pub truncation: Option, + pub seq: u64, +} + +/// 007 O2: a named checkpoint/history point (the `CheckpointList` entry). The +/// adapter normalizes each backend's list capability; `id` is backend-native +/// (adapter-private semantics, UI only echoes it back). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct CheckpointEntry { + pub id: String, + pub label: Option, + pub turn_gen: Option, +} + +/// Context-window budget on a [`SessionEvent::SessionInfo`] (claude +/// `get_context_usage`: `totalTokens` / `maxTokens`). `categories` carries the +/// optional per-bucket breakdown (system prompt / skills / messages / free) as +/// neutral `(name, tokens)` pairs — the backend owns the names; we do not +/// interpret them. `used`/`max` are the headline numbers a context-budget bar reads. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ContextUsage { + pub used: u64, + pub max: u64, + #[serde(default)] + pub categories: Vec, +} + +/// One per-bucket entry in a [`ContextUsage`] breakdown. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ContextUsageCategory { + pub name: String, + pub tokens: u64, +} + +/// 007 §1.2/§7.2 (Addendum 2b): the produced/lowered classifier — first-class +/// pure total fn, NOT an enum split (which would break the reducer's single +/// `match` entry). The storage layer calls this to decide what to append to the +/// durable rewind log. `step()` NEVER calls it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EventClass { + /// Self-describing observation of what happened → candidate for PERSIST. + BackendProduced, + /// Internal decision lowered from Command/janitor/orchestration → NOT persisted. + OrchestrationLowered, +} + +/// Total over `SessionEvent` (§9.0): adding a future variant forces a decision +/// here — no silent misclassification. `step()` does not consult it. +pub fn classify(event: &SessionEvent) -> EventClass { + use SessionEvent::*; + match event { + // orchestration-lowered (internal; NOT persisted). BackendBound is here + // (Addendum 9): an adapter-translated binding pass-through, NOT a backend + // observation — it must NOT enter the append-only stream or rewind replay. + TurnStarted { .. } + | Cancel + | Heartbeat + | Snapshot { .. } + | Lagged { .. } + | BackendBound { .. } + | BackendSuspended => EventClass::OrchestrationLowered, + // backend-produced (self-describing; PERSISTED — tier decided separately, §7.2) + MessageDelta { .. } + | ThoughtDelta { .. } + | ToolCall { .. } + | ToolResult { .. } + | TurnResult { .. } + | Detached { .. } + | AdapterSpecific { .. } + | Plan { .. } + | Permission { .. } + | PermissionResolved { .. } + | PromptAccepted { .. } + | UsageDelta { .. } + | Provisioning { .. } + | Rewound { .. } + | ConfigChanged { .. } + | CatalogUpdated { .. } + | SubagentUpdate { .. } + | SubagentDetail { .. } + | Notice { .. } + | ToolOutputDelta { .. } + | TurnDiffUpdated { .. } + | ItemStarted { .. } + | ItemCompleted { .. } + | MessageFinalized(..) + | SessionInfo { .. } + | CheckpointList { .. } => EventClass::BackendProduced, + } +} + +/// Two-axis persistence routing (§7.2): `classify` is the first gate; within +/// `BackendProduced`, the storage owner decides display-vs-state. Consulted ONLY +/// by the persistence layer, never the reducer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PersistTier { + /// UI live stream, never stored (deltas pushed-every-chunk-but-not-persisted). + Ephemeral, + /// Tier-1 history render (finalized fold + tool pairs + usage + config). + Display, + /// Tier-2 state-rebuild skeleton (turn boundary / outcome / permission). + State, + /// Both display AND state (e.g. TurnResult: text→display, outcome→state). + DisplayAndState, +} + +pub fn persist_tier(event: &SessionEvent) -> PersistTier { + use SessionEvent::*; + match classify(event) { + EventClass::OrchestrationLowered => PersistTier::Ephemeral, + EventClass::BackendProduced => match event { + MessageDelta { .. } | ThoughtDelta { .. } => PersistTier::Ephemeral, // deltas folded → MessageFinalized + // live tool-output / turn-diff streams: never persisted — the durable + // record is the completed ToolResult (full aggregatedOutput / per-file diff). + ToolOutputDelta { .. } | TurnDiffUpdated { .. } => PersistTier::Ephemeral, + ItemStarted { .. } | ItemCompleted { .. } => PersistTier::Ephemeral, // lifecycle brackets + CheckpointList { .. } => PersistTier::Ephemeral, // query response, not history + CatalogUpdated { .. } => PersistTier::Ephemeral, // async catalog discovery, re-discovered on open (not history) + SessionInfo { .. } => PersistTier::Ephemeral, // on-demand query snapshot, re-queryable (not history) + SubagentDetail { .. } => PersistTier::Ephemeral, // transient per-agent progress (roster fill, re-derivable) + Plan { .. } => PersistTier::Ephemeral, // LC-8a: live to-do snapshot, full-replace + re-derivable (not history) + ToolCall { .. } + | ToolResult { .. } + | UsageDelta { .. } + | Provisioning { .. } + | ConfigChanged { .. } + // an out-of-turn advisory the user should keep seeing in history + | Notice { .. } + | MessageFinalized(..) => PersistTier::Display, + TurnResult { .. } => PersistTier::DisplayAndState, // text→display; outcome→state + Permission { .. } | PermissionResolved { .. } | Detached { .. } | PromptAccepted { .. } => { + PersistTier::DisplayAndState + } + SubagentUpdate { .. } => PersistTier::DisplayAndState, // roster→display; resumable→Tier2.last_subagents + Rewound { .. } => PersistTier::State, // turn-truncation anchor + AdapterSpecific { tag, .. } if is_raw_timing(tag) => PersistTier::Ephemeral, + AdapterSpecific { .. } => PersistTier::Display, + // Orchestration-lowered already handled above; this arm is unreachable + // for them but keeps the match total over the enum. + TurnStarted { .. } + | Cancel + | Heartbeat + | Snapshot { .. } + | Lagged { .. } + | BackendBound { .. } + | BackendSuspended => PersistTier::Ephemeral, + }, + } +} + +/// AdapterSpecific tags carrying raw timing diagnostics (ttft_ms / elapsed) are +/// Tier-0; structured tags are Tier-1. +fn is_raw_timing(tag: &str) -> bool { + matches!(tag, "ttft_ms" | "elapsed" | "latency") +} + +/// Trimmed exit status (does not leak the full `std::process::ExitStatus` +/// surface into the contract). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ExitStatusLite { + /// `None` when killed by a signal (no exit code). + pub code: Option, + /// Unix signal, if any. + pub signal: Option, +} + +/// Crash-discriminator return (FIX 4b/FIX 6b). `crash_outcome` is a pure +/// `fn(terminal_result_seen, exit) -> Outcome`; orchestration uses it to route +/// the `step` of a `Detached`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + /// Exited ABNORMALLY (signal, or non-zero code) before a terminal result + /// this turn → maps to `SessionState::Error{ reason: Crashed }`. + Crashed, + /// Exited CLEANLY (code 0, no signal) but before any terminal result this + /// turn → maps to `SessionState::Error{ reason: EmptyTurn }`. F46: a clean + /// exit-0 with no result is NOT a crash — conflating it with a SIGKILL crash + /// fed the wrong recovery disposition to the control plane. The exit payload + /// (code/signal) is the discriminator the reducer previously discarded. + CleanNoResult, + /// A result was seen (or EOF-synthesized for a terminal_result=false + /// backend) → follow the result path; the real terminal was already decided + /// by the prior `TurnResult`, and the `Detached` is absorbed (I10). + FollowResult, +} + +#[cfg(test)] +mod additive_tests { + //! 007 §C2 verification items for the additive vocabulary (P0a). Proves: + //! (1) classify/persist_tier are TOTAL and route new variants correctly; + //! (2) SessionEvent serde round-trips (Tier-1/2 durability, Addendum 2a); + //! (3) the additive defaults keep older frames deserializable. + use super::*; + + #[test] + fn classify_routes_orchestration_vs_backend() { + // orchestration-lowered → NOT persisted + assert_eq!(classify(&SessionEvent::Heartbeat), EventClass::OrchestrationLowered); + assert_eq!( + classify(&SessionEvent::Lagged { skipped: 3 }), + EventClass::OrchestrationLowered + ); + assert_eq!( + classify(&SessionEvent::Snapshot { + state_repr: "Idle".into(), + turn_gen: 1 + }), + EventClass::OrchestrationLowered + ); + // backend-produced → persisted (tier decided separately) + assert_eq!( + classify(&SessionEvent::PromptAccepted { + client_msg_id: "m1".into() + }), + EventClass::BackendProduced + ); + assert_eq!( + classify(&SessionEvent::SubagentUpdate { + r#ref: "a1".into(), + label: None, + status: SubagentStatus::Running, + parent_ref: None + }), + EventClass::BackendProduced + ); + assert_eq!( + classify(&SessionEvent::ConfigChanged { + mode: Some("plan".into()), + model: None + }), + EventClass::BackendProduced + ); + // Addendum 9: BackendBound is OrchestrationLowered → NOT persisted (a + // pass-through for the conversation, never an append-only/rewind event). + assert_eq!( + classify(&SessionEvent::BackendBound { + backend_session_id: Some("th-1".into()) + }), + EventClass::OrchestrationLowered + ); + assert_eq!( + persist_tier(&SessionEvent::BackendBound { + backend_session_id: None + }), + PersistTier::Ephemeral, + "BackendBound is never persisted as an event" + ); + } + + /// ENUMERATION INVARIANT (§7.2/§9.0). `classify_routes_orchestration_vs_backend` + /// and `persist_tier_two_axis_routing` each SPOT-CHECK a handful of variants — the + /// compiler proves the matches are TOTAL, but a mis-wired arm (e.g. a BackendProduced + /// variant returning OrchestrationLowered, silently dropping it from persistence) + /// compiles fine and the spot-checks miss it. This pins the routing DECISION for + /// EVERY variant in one table: each SessionEvent paired with its expected + /// (EventClass, PersistTier). A new variant forces this table to grow (count assert), + /// so it cannot be added without an explicit persistence decision recorded here. + #[test] + fn every_session_event_variant_routes_to_expected_class_and_tier() { + use EventClass::*; + use PersistTier::*; + // (label, event, expected class, expected tier). Keep in lockstep with the + // SessionEvent enum — the count assert below is the tripwire. + let table: Vec<(&str, SessionEvent, EventClass, PersistTier)> = vec![ + // ── orchestration-lowered (never persisted → Ephemeral) ── + ( + "TurnStarted", + SessionEvent::TurnStarted { epoch: 1 }, + OrchestrationLowered, + Ephemeral, + ), + ("Cancel", SessionEvent::Cancel, OrchestrationLowered, Ephemeral), + ("Heartbeat", SessionEvent::Heartbeat, OrchestrationLowered, Ephemeral), + ( + "Snapshot", + SessionEvent::Snapshot { + state_repr: "Idle".into(), + turn_gen: 1, + }, + OrchestrationLowered, + Ephemeral, + ), + ( + "Lagged", + SessionEvent::Lagged { skipped: 1 }, + OrchestrationLowered, + Ephemeral, + ), + ( + "BackendBound", + SessionEvent::BackendBound { + backend_session_id: None, + }, + OrchestrationLowered, + Ephemeral, + ), + ( + "BackendSuspended", + SessionEvent::BackendSuspended, + OrchestrationLowered, + Ephemeral, + ), + // ── backend-produced, Ephemeral (live/re-derivable, not history) ── + ( + "MessageDelta", + SessionEvent::MessageDelta { + item_id: "i".into(), + text: "t".into(), + }, + BackendProduced, + Ephemeral, + ), + ( + "ThoughtDelta", + SessionEvent::ThoughtDelta { + item_id: "i".into(), + text: "t".into(), + }, + BackendProduced, + Ephemeral, + ), + ( + "Notice", + SessionEvent::Notice { + level: NoticeLevel::Warning, + message: "config key X is deprecated".into(), + }, + BackendProduced, + Display, + ), + ( + "ToolOutputDelta", + SessionEvent::ToolOutputDelta { + item_id: "call_0".into(), + text: "line-2\n".into(), + }, + BackendProduced, + Ephemeral, + ), + ( + "TurnDiffUpdated", + SessionEvent::TurnDiffUpdated { + diff: "diff --git a/x b/x".into(), + }, + BackendProduced, + Ephemeral, + ), + ( + "ItemStarted", + SessionEvent::ItemStarted { + item_id: "i".into(), + kind: ItemKind::Tool, + }, + BackendProduced, + Ephemeral, + ), + ( + "ItemCompleted", + SessionEvent::ItemCompleted { + item_id: "i".into(), + truncation: None, + }, + BackendProduced, + Ephemeral, + ), + ( + "CheckpointList", + SessionEvent::CheckpointList { entries: vec![] }, + BackendProduced, + Ephemeral, + ), + ( + "SessionInfo", + SessionEvent::SessionInfo { + context_usage: None, + cost_text: Some("Total cost: $0".into()), + }, + BackendProduced, + Ephemeral, + ), + ( + "SubagentDetail", + SessionEvent::SubagentDetail { + r#ref: "a".into(), + parent_ref: None, + label: None, + loop_state: None, + model: None, + tokens: None, + tool_calls: None, + last_tool_name: None, + }, + BackendProduced, + Ephemeral, + ), + ( + "Plan", + SessionEvent::Plan { + entries: vec![], + explanation: None, + }, + BackendProduced, + Ephemeral, + ), + ( + "AdapterSpecific(raw-timing)", + SessionEvent::AdapterSpecific { + tag: "ttft_ms".into(), + payload: serde_json::json!(1), + }, + BackendProduced, + Ephemeral, + ), + // ── backend-produced, Display ── + ( + "ToolCall", + SessionEvent::ToolCall { + tool_use_id: "t".into(), + name: "Bash".into(), + subagent: SubagentKind::Inline, + input: serde_json::Value::Null, + parent_tool_use_id: None, + }, + BackendProduced, + Display, + ), + ( + "ToolResult", + SessionEvent::ToolResult { + tool_use_id: "t".into(), + is_error: false, + content: vec![], + parent_tool_use_id: None, + }, + BackendProduced, + Display, + ), + ( + "UsageDelta", + SessionEvent::UsageDelta { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + cost_usd: None, + }, + BackendProduced, + Display, + ), + ( + "Provisioning", + SessionEvent::Provisioning { + phase: ProvisioningPhase::ToolsWaiting, + }, + BackendProduced, + Display, + ), + ( + "ConfigChanged", + SessionEvent::ConfigChanged { + mode: None, + model: Some("opus".into()), + }, + BackendProduced, + Display, + ), + ( + "MessageFinalized", + SessionEvent::MessageFinalized(FinalizedMessage { + item_id: "m".into(), + kind: ItemKind::Text, + content: "c".into(), + truncation: None, + seq: 1, + }), + BackendProduced, + Display, + ), + ( + "AdapterSpecific(structured)", + SessionEvent::AdapterSpecific { + tag: "config".into(), + payload: serde_json::json!({}), + }, + BackendProduced, + Display, + ), + // ── backend-produced, DisplayAndState ── + ( + "TurnResult", + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "done".into(), + epoch: 1, + outcome: TurnOutcome::default(), + }, + BackendProduced, + DisplayAndState, + ), + ( + "Permission", + SessionEvent::Permission { + request_id: "r".into(), + kind: PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + BackendProduced, + DisplayAndState, + ), + ( + "PermissionResolved", + SessionEvent::PermissionResolved { + request_id: "r".into(), + kind: PermissionKind::Tool, + }, + BackendProduced, + DisplayAndState, + ), + ( + "Detached", + SessionEvent::Detached { + exit: None, + redacted_summary: None, + }, + BackendProduced, + DisplayAndState, + ), + ( + "PromptAccepted", + SessionEvent::PromptAccepted { + client_msg_id: "m".into(), + }, + BackendProduced, + DisplayAndState, + ), + ( + "SubagentUpdate", + SessionEvent::SubagentUpdate { + r#ref: "a".into(), + label: None, + status: SubagentStatus::Running, + parent_ref: None, + }, + BackendProduced, + DisplayAndState, + ), + // ── backend-produced, State ── + ("Rewound", SessionEvent::Rewound { to_turn: 1 }, BackendProduced, State), + ]; + + // Tripwire: every SessionEvent variant must appear. 32 variants today + // (7 orchestration-lowered + 25 backend-produced, incl. Notice + + // ToolOutputDelta + TurnDiffUpdated + SessionInfo); AdapterSpecific appears + // twice for its raw-timing vs structured split → 33 rows. A new variant trips. + assert_eq!( + table.len(), + 33, + "every SessionEvent variant (+ the AdapterSpecific timing split) must be routed here" + ); + + for (label, ev, want_class, want_tier) in &table { + assert_eq!( + classify(ev), + *want_class, + "[{label}] classify mismatch — a misrouted class silently changes persistence" + ); + assert_eq!(persist_tier(ev), *want_tier, "[{label}] persist_tier mismatch"); + } + } + + #[test] + fn persist_tier_two_axis_routing() { + // deltas: ephemeral (pushed-not-stored) + assert_eq!( + persist_tier(&SessionEvent::MessageDelta { + item_id: "i".into(), + text: "hi".into() + }), + PersistTier::Ephemeral + ); + // finalized fold + config: display + assert_eq!( + persist_tier(&SessionEvent::ConfigChanged { + mode: None, + model: Some("opus".into()) + }), + PersistTier::Display + ); + // subagent roster: display + state (resumable) + assert_eq!( + persist_tier(&SessionEvent::SubagentUpdate { + r#ref: "a".into(), + label: None, + status: SubagentStatus::Completed, + parent_ref: None + }), + PersistTier::DisplayAndState + ); + // rewind anchor: state only + assert_eq!(persist_tier(&SessionEvent::Rewound { to_turn: 4 }), PersistTier::State); + // raw timing AdapterSpecific: ephemeral; structured: display + assert_eq!( + persist_tier(&SessionEvent::AdapterSpecific { + tag: "ttft_ms".into(), + payload: serde_json::json!(120) + }), + PersistTier::Ephemeral + ); + assert_eq!( + persist_tier(&SessionEvent::AdapterSpecific { + tag: "config".into(), + payload: serde_json::json!({}) + }), + PersistTier::Display + ); + } + + #[test] + fn session_event_serde_round_trip() { + // GAP-G (R12 / V4-serde): EXHAUSTIVE over the BackendProduced variants (the + // persisted set) PLUS the named boundary cases — MessageDelta text="", + // Detached exit=None, SubagentUpdate parent_ref=None, BackendBound None. + // (Orchestration-lowered variants are never persisted, so they're not part + // of the round-trip contract; included a couple anyway since they derive + // serde and must not regress.) + let events = vec![ + // --- boundary cases (V4-serde named) --- + SessionEvent::MessageDelta { + item_id: "m1".into(), + text: String::new(), // empty text boundary + }, + SessionEvent::Detached { + exit: None, + redacted_summary: None, + }, // exit=None boundary + SessionEvent::SubagentUpdate { + r#ref: "a0".into(), + label: None, + status: SubagentStatus::Running, + parent_ref: None, // top-level boundary + }, + SessionEvent::BackendBound { + backend_session_id: None, // lost-binding boundary + }, + // --- every BackendProduced variant (the persisted set) --- + SessionEvent::ThoughtDelta { + item_id: "th1".into(), + text: "reasoning".into(), + }, + SessionEvent::ToolCall { + tool_use_id: "t1".into(), + name: "Bash".into(), + subagent: SubagentKind::Spawned { + session_id: "child-1".into(), + }, + input: serde_json::json!({"command": "ls -la"}), + // 009 H5: a non-None parent exercises the new field's serde round-trip. + parent_tool_use_id: Some("toolu_parent_1".into()), + }, + SessionEvent::ToolResult { + tool_use_id: "t1".into(), + is_error: false, + content: vec![ + ToolResultContent::Text("out".into()), + ToolResultContent::FilePath { + path: "/w/a.png".into(), + mime: Some("image/png".into()), + old_text: None, + new_text: None, + }, + ], + parent_tool_use_id: Some("toolu_parent_1".into()), + }, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "done".into(), + epoch: 7, + outcome: TurnOutcome::Completed { + stop_reason: StopReason::Truncated(TruncationKind::MaxTokens), + }, + }, + SessionEvent::AdapterSpecific { + tag: "x".into(), + payload: serde_json::json!({"k": 1}), + }, + SessionEvent::Permission { + request_id: "r1".into(), + kind: PermissionKind::Auth, + metadata: None, + tool_name: None, + input: None, + }, + SessionEvent::PermissionResolved { + request_id: "r1".into(), + kind: PermissionKind::Tool, + }, + SessionEvent::PromptAccepted { + client_msg_id: "m-7".into(), + }, + SessionEvent::UsageDelta { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + cost_usd: Some(0.0021), + }, + SessionEvent::Provisioning { + phase: ProvisioningPhase::ToolsWaiting, + }, + SessionEvent::Rewound { to_turn: 3 }, + SessionEvent::ConfigChanged { + mode: Some("plan".into()), + model: None, + }, + SessionEvent::SubagentUpdate { + r#ref: "agent-9".into(), + label: Some("reviewer".into()), + status: SubagentStatus::Interrupted, + parent_ref: Some("wf-root".into()), + }, + SessionEvent::Notice { + level: NoticeLevel::Info, + message: "deprecated: use --foo".into(), + }, + SessionEvent::ToolOutputDelta { + item_id: "call_0".into(), + text: "line-2\n".into(), + }, + SessionEvent::TurnDiffUpdated { + diff: "diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n".into(), + }, + SessionEvent::ItemStarted { + item_id: "i1".into(), + kind: ItemKind::Tool, + }, + SessionEvent::ItemCompleted { + item_id: "i1".into(), + truncation: None, + }, + SessionEvent::MessageFinalized(FinalizedMessage { + item_id: "m1".into(), + kind: ItemKind::Text, + content: "final".into(), + truncation: None, + seq: 1, + }), + SessionEvent::CheckpointList { + entries: vec![CheckpointEntry { + id: "ck1".into(), + label: Some("before refactor".into()), + turn_gen: Some(3), + }], + }, + SessionEvent::SessionInfo { + context_usage: Some(ContextUsage { + used: 3025, + max: 200000, + categories: vec![ContextUsageCategory { + name: "System prompt".into(), + tokens: 1460, + }], + }), + cost_text: None, + }, + SessionEvent::SessionInfo { + context_usage: None, + cost_text: Some("Total cost: $0.1180".into()), + }, + ]; + for ev in events { + let json = serde_json::to_string(&ev).expect("serialize"); + let back: SessionEvent = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(ev, back, "round-trip must be lossless for {json}"); + } + } + + /// ENUMERATION INVARIANT (wire-durability, §9.5). `TurnResult.outcome` is persisted + /// (Tier-2 state-rebuild) and read back on rehydrate, so the `TurnOutcome` / + /// `StopReason` / `TruncationKind` / `CancelReason` family must serde round-trip + /// losslessly for EVERY variant — a silent `#[serde]` rename or an un-round-trippable + /// shape would corrupt a rebuilt turn's outcome. The session_event_serde_round_trip + /// test only carried ONE outcome (Truncated(MaxTokens)); this sweeps the whole family. + #[test] + fn every_turn_outcome_variant_round_trips() { + let truncations = [ + TruncationKind::MaxTokens, + TruncationKind::ContextWindow, + TruncationKind::MaxTurns, + TruncationKind::Budget, + TruncationKind::Truncated, + ]; + let cancels = [ + CancelReason::UserCancel, + CancelReason::BargeIn, + CancelReason::SocketAbort, + CancelReason::ToolDenyChain, + ]; + + // The full TurnOutcome value space: EndTurn, Failed, every Completed{stop_reason} + // (EndTurn / Refused{Some,None} / Truncated(each kind)), and every Cancelled{reason}. + let mut outcomes: Vec = vec![ + TurnOutcome::EndTurn, + TurnOutcome::Failed, + TurnOutcome::Completed { + stop_reason: StopReason::EndTurn, + }, + TurnOutcome::Completed { + stop_reason: StopReason::Refused { + category: Some("safety".into()), + }, + }, + TurnOutcome::Completed { + stop_reason: StopReason::Refused { category: None }, + }, + ]; + outcomes.extend(truncations.iter().map(|k| TurnOutcome::Completed { + stop_reason: StopReason::Truncated(*k), + })); + outcomes.extend(cancels.iter().map(|r| TurnOutcome::Cancelled { reason: *r })); + + // Tripwire: 5 base/Completed shapes + 5 truncation kinds + 4 cancel reasons. + // A new TruncationKind / CancelReason / StopReason / TurnOutcome variant grows + // this and forces an explicit round-trip decision. + assert_eq!( + outcomes.len(), + 5 + truncations.len() + cancels.len(), + "every TurnOutcome / StopReason / TruncationKind / CancelReason variant must be swept" + ); + + for outcome in &outcomes { + let json = serde_json::to_string(outcome).expect("serialize outcome"); + let back: TurnOutcome = serde_json::from_str(&json).expect("deserialize outcome"); + assert_eq!(outcome, &back, "TurnOutcome must round-trip losslessly: {json}"); + } + + // Also pin TruncationKind in isolation (it rides ItemCompleted.truncation + + // FinalizedMessage.truncation too, not only via StopReason). + for k in &truncations { + let json = serde_json::to_string(k).expect("serialize truncation"); + let back: TruncationKind = serde_json::from_str(&json).expect("deserialize truncation"); + assert_eq!(k, &back, "TruncationKind must round-trip: {json}"); + } + } + + #[test] + fn additive_defaults_keep_old_frames_deserializable() { + // An OLD TurnResult frame (no `outcome`) must still deserialize (the + // #[serde(default)] guarantee) → outcome defaults to EndTurn. + let old = r#"{"TurnResult":{"is_error":false,"api_error_status":null,"result_text":"hi","epoch":2}}"#; + let ev: SessionEvent = serde_json::from_str(old).expect("old frame deserializes"); + match ev { + SessionEvent::TurnResult { outcome, .. } => { + assert_eq!(outcome, TurnOutcome::default(), "missing outcome → default"); + } + other => panic!("expected TurnResult, got {other:?}"), + } + // An OLD ToolCall frame (no `subagent`, no `input`) → subagent defaults + // Inline AND input defaults to Value::Null (Gap #4 additive #[serde(default)]). + let old_tc = r#"{"ToolCall":{"tool_use_id":"t","name":"Read"}}"#; + let ev: SessionEvent = serde_json::from_str(old_tc).expect("old ToolCall deserializes"); + match ev { + SessionEvent::ToolCall { + subagent, ref input, .. + } => { + assert_eq!(subagent, SubagentKind::Inline, "missing subagent → Inline"); + assert!(input.is_null(), "missing input → Value::Null (Gap #4 default)"); + } + other => panic!("expected ToolCall, got {other:?}"), + } + // An OLD Permission frame (no `kind`, no `metadata`) → defaults Tool + None. + let old_p = r#"{"Permission":{"request_id":"r"}}"#; + let ev: SessionEvent = serde_json::from_str(old_p).expect("old Permission deserializes"); + assert!(matches!( + ev, + SessionEvent::Permission { + kind: PermissionKind::Tool, + metadata: None, + .. + } + )); + // A NEW Permission frame round-trips its G3 metadata verbatim. + let new_p = r#"{"Permission":{"request_id":"r","kind":"Tool","metadata":{"server_name":"aionui-team"}}}"#; + let ev: SessionEvent = serde_json::from_str(new_p).expect("new Permission deserializes"); + match ev { + SessionEvent::Permission { metadata, .. } => { + assert_eq!( + metadata + .as_ref() + .and_then(|m| m.get("server_name")) + .and_then(|v| v.as_str()), + Some("aionui-team") + ); + } + other => panic!("expected Permission, got {other:?}"), + } + // The OLDEST ToolResult frame pre-dates BOTH `is_error` (009 R7) and + // `content` (009 R8) → both #[serde(default)]: is_error→false, content→[]. + // (A pre-R7 frame carried only `tool_use_id`; without the is_error default it + // would fail to deserialize and a reload would lose the whole tool step.) + let old_tr = r#"{"ToolResult":{"tool_use_id":"t"}}"#; + let ev: SessionEvent = serde_json::from_str(old_tr).expect("oldest ToolResult deserializes"); + match ev { + SessionEvent::ToolResult { is_error, content, .. } => { + assert!(!is_error, "missing is_error → false (success)"); + assert!(content.is_empty(), "missing content → empty Vec"); + } + other => panic!("expected ToolResult, got {other:?}"), + } + // An OLD Detached frame (no `redacted_summary`, G2 post-dates it) → None. + let old_d = r#"{"Detached":{"exit":null}}"#; + let ev: SessionEvent = serde_json::from_str(old_d).expect("old Detached deserializes"); + match ev { + SessionEvent::Detached { redacted_summary, .. } => { + assert!( + redacted_summary.is_none(), + "missing redacted_summary → None (G2 default)" + ); + } + other => panic!("expected Detached, got {other:?}"), + } + // LC-8a: a Plan frame round-trips entries (status enum + optional priority). + let plan = r#"{"Plan":{"entries":[{"content":"a","status":"InProgress","priority":"High"},{"content":"b","status":"Pending"}],"explanation":"why"}}"#; + let ev: SessionEvent = serde_json::from_str(plan).expect("Plan deserializes"); + match ev { + SessionEvent::Plan { entries, explanation } => { + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].status, PlanStatus::InProgress); + assert_eq!(entries[0].priority, Some(PlanPriority::High)); + assert!(entries[1].priority.is_none(), "missing priority → None (serde default)"); + assert_eq!(explanation.as_deref(), Some("why")); + } + other => panic!("expected Plan, got {other:?}"), + } + + // A NEW Detached frame round-trips its summary verbatim. + let new_d = r#"{"Detached":{"exit":null,"redacted_summary":"usage limit exceeded"}}"#; + let ev: SessionEvent = serde_json::from_str(new_d).expect("new Detached deserializes"); + match ev { + SessionEvent::Detached { redacted_summary, .. } => { + assert_eq!(redacted_summary.as_deref(), Some("usage limit exceeded")); + } + other => panic!("expected Detached, got {other:?}"), + } + } +} diff --git a/crates/aionui-session/src/lib.rs b/crates/aionui-session/src/lib.rs new file mode 100644 index 000000000..9fcdeb373 --- /dev/null +++ b/crates/aionui-session/src/lib.rs @@ -0,0 +1,79 @@ +//! `aionui-session` — server-authoritative SessionState control plane (feature +//! 002). Domain layer. +//! +//! ## What this crate is +//! It spawns the claude CLI directly (via 001 `aionui-process`, no ACP/SDK), +//! frames + parses its stream-json output into a backend-agnostic +//! [`SessionEvent`] vocabulary, and folds that — through a single monomorphic +//! pure reducer ([`step`]) — into a 5-name / 4-variant [`SessionState`] FSM. +//! The unlock signal ([`can_send_message`] flipping true) is carried by a +//! [`Transition`] emitted AT THE MOMENT OF TRANSITION, decoupled from any +//! blocking call return. +//! +//! ## Honest boundary (necessary-not-sufficient) +//! This crate does NOT, by itself, fix the user-visible "workflow freezes the +//! conversation" bug. The load-bearing coupling (UI-unlock ← turn.completed ← +//! StreamRelay finalize ← blocking prompt() return) lives in +//! `aionui-conversation` + WebSocket — all Non-Goal here, untouched. This crate +//! PROVES the necessary mechanism: a transition-driven unlock signal decoupled +//! from blocking returns. Wiring it to UI-unlock is feature 003+. +//! +//! ## Layering +//! Domain layer, parallel to `aionui-ai-agent`. Depends ONLY on +//! `aionui-process` (001) + `aionui-common`. NO production caller this +//! iteration — built and verified in isolation (same discipline as 001). +//! +//! ## Two-stage seam +//! - a-side: the [`SessionBackend`] / [`BackendConnection`] traits (claude/codex/ +//! acp/aionrs impls in `backend/`) — the ONLY backend-aware code: startup+prompt +//! / framing+parse / capability declaration. (The original claude lane wrapped a +//! legacy `BackendAdapter`; `ClaudeConnection` still wraps it internally.) +//! - b-side: the monomorphic [`step`] reducer + oracles — shared by all +//! backends; a backend can only PRODUCE `SessionEvent`, never touch the FSM. +//! Adding a backend = a new `SessionBackend` impl; reducer/FSM/oracle change 0 lines. + +mod adapter; +mod backend; +mod capability; +mod error; +mod event; +mod reducer; +mod state; + +#[cfg(any(test, feature = "test-support"))] +pub mod testing; + +// The `adapter` module is the legacy claude spawn+parse path, now wrapped by the +// `ClaudeConnection` strangler (backend/claude_conn.rs) which reaches it via +// `crate::adapter::`. Only the transport types (`AgentIo`/`ManagedProcessIo`) have +// external consumers; the legacy `BackendAdapter`/`ClaudeAdapter`/`SessionSpec` are +// internal-only and no longer re-exported (DUP-10). +pub use adapter::{AgentIo, ManagedProcessIo, is_valid_claude_permission_mode}; +// 007 §C1 seam: the live Command/SessionSpec/etc. DUP-10 removed the legacy +// `command`/`session` modules that forced the `BackendCommand`/`BackendSessionSpec` +// aliases, so these are now exported under their plain names. +pub use backend::{ + AcpConnection, AcpSessionBackend, Admission, BackendConnection, BackendError, CancelTarget, ClaudeConnection, + ClaudeSessionBackend, CodexConnection, CodexSessionBackend, Command, CommandMeta, CommandReceipt, ContentBlock, + ConversationSession, McpServerSpec, McpTransport, MsgStatus, Orchestrator, PendingMessage, PendingPermissionView, + PermissionDecision, QuestionAnswer, SessionBackend, SessionConfig, SessionEnvelope, SessionInfoKind, SessionInit, + SessionSpec, StateSnapshot, Tier2Checkpoint, TransitionReason, acp_capabilities, codex_capabilities, command_name, + rehydrate, +}; +pub use capability::{ + BlockSet, Capabilities, CapabilityTier, CommandSet, ModeInfo, ModelInfo, PromptAcceptedSource, SignalSet, + SlashCommandInfo, block_kind_name, +}; +pub use error::SessionError; +pub use event::{ + CancelReason, CheckpointEntry, EventClass, ExitStatusLite, FinalizedMessage, ItemKind, NoticeLevel, Outcome, + PermissionKind, PersistTier, PlanEntry, PlanPriority, PlanStatus, ProvisioningPhase, SessionEvent, StopReason, + SubagentKind, SubagentStatus, ToolResultContent, TruncationInfo, TruncationKind, TurnOutcome, classify, + persist_tier, +}; +pub use reducer::{Transition, crash_outcome, step}; +pub use state::{ + ErrorReason, RequiresActionSet, SessionState, SubagentState, WorkflowAgentState, WorkflowLoopState, + WorkflowTaskStatus, background_active, can_cancel, can_queue_message, can_send_message, has_foreground_activity, + is_requires_action, is_unrecoverable_resume_error, +}; diff --git a/crates/aionui-session/src/reducer.rs b/crates/aionui-session/src/reducer.rs new file mode 100644 index 000000000..303e55854 --- /dev/null +++ b/crates/aionui-session/src/reducer.rs @@ -0,0 +1,2274 @@ +//! The monomorphic, pure reducer (seam b-side, §C 6.4). `step` is the ONLY +//! state-synthesis point, shared by all `BackendAdapter`s. No I/O, no clock, no +//! backend-aware branch (I1). Per-turn memory lives in `SessionState::Running` +//! so the fn output depends only on `(state, event)`. + +use crate::event::{ExitStatusLite, Outcome, SessionEvent}; +use crate::state::{ErrorReason, RequiresActionSet, SessionState}; + +/// Emitted on every FSM transition. The reducer (`step`/`settle`) produces these; +/// the orchestrator's `derive_reason` reads `from`/`to` to label the unlock signal. +/// Carries only `SessionState` (no opaque payload), so total equality is +/// meaningful ⇒ derives `Eq` (unlike `SessionEvent`). DUP-10: relocated here from +/// the deleted legacy `command` module (its sole producer is this reducer). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Transition { + pub from: SessionState, + pub to: SessionState, + /// Minimal turn-epoch (R11/D9), monotonic. + pub epoch: u64, +} + +/// An externally-meaningful descriptor of a state, used to decide whether a +/// `Transition` must be broadcast. Pure-accumulator flips inside `Running` +/// (saw_substantive_output / terminal_result_seen) do NOT change this, so they +/// update state silently; category changes and requires-action crossing zero +/// DO change it and emit a `Transition` (they move can_send / deadline-scope / +/// the RA badge). +#[derive(PartialEq, Eq)] +enum ExternalPhase { + Starting, + Running, + RequiresAction, + Error, + Idle, +} + +fn external_phase(s: &SessionState) -> ExternalPhase { + match s { + SessionState::Starting => ExternalPhase::Starting, + // Single source of truth for the requires-action predicate: reuse the + // public `is_requires_action` (its `> 0` lives in state.rs and is + // directly tested), so there is no duplicate comparison here to drift. + SessionState::Running { .. } if crate::state::is_requires_action(s) => ExternalPhase::RequiresAction, + SessionState::Running { .. } => ExternalPhase::Running, + SessionState::Error { .. } => ExternalPhase::Error, + SessionState::Idle => ExternalPhase::Idle, + } +} + +/// Build the `(state, transitions)` return: emit a `Transition` iff the external +/// phase changed. `epoch` is the turn epoch to stamp on the transition. +fn settle(from: SessionState, to: SessionState, epoch: u64) -> (SessionState, Vec) { + let transitions = if external_phase(&from) != external_phase(&to) { + vec![Transition { + from, + to: to.clone(), + epoch, + }] + } else { + Vec::new() + }; + (to, transitions) +} + +/// The turn epoch a state is anchored to (Running's `since_epoch`); terminal / +/// starting states have no anchor (0). Only used to stamp transitions and as +/// the epoch-guard baseline while Running. +fn anchor_epoch(s: &SessionState) -> u64 { + match s { + SessionState::Running { since_epoch, .. } => *since_epoch, + _ => 0, + } +} + +/// seam b-side, monomorphic pure fn. epoch single-home = `TurnStarted{epoch}` +/// (I3): `Running.since_epoch` is the reducer's baseline; `step` takes no +/// separate epoch scalar. Commands arrive as `SessionEvent::TurnStarted`. There +/// is NO clock input: AionCore imposes no auto-timeout on any state (the deadline +/// janitor + `Timeout` event were removed) — a wedged turn ends by user Cancel, a +/// dead process by `Detached`. The reducer never reads a clock. +pub fn step(state: &SessionState, event: SessionEvent) -> (SessionState, Vec) { + match event { + // ---- TurnStarted: the ONLY event that leaves a terminal state. ---- + // Epoch guard (I3): while Running, a TurnStarted whose epoch does not + // advance past since_epoch is stale/duplicate → dropped. (Cross-turn + // stale DATA events can't occur: turn == process boundary, D9.) + SessionEvent::TurnStarted { epoch } => { + if let SessionState::Running { since_epoch, .. } = state + && epoch <= *since_epoch + { + return (state.clone(), Vec::new()); // stale / duplicate + } + let to = SessionState::Running { + since_epoch: epoch, + saw_substantive_output: false, + terminal_result_seen: false, + requires_action: RequiresActionSet::default(), + subagents: Vec::new(), // §6b b1: fresh turn starts with an empty roster + }; + settle(state.clone(), to, epoch) + } + + // ---- Terminal-absorbing law (I10): once Idle/Error, ignore all ---- + // events except TurnStarted (handled above). This covers same-turn late + // results, a self-inflicted-Timeout's later Detached (no re-Crash), a + // cancel-then-kill's later Detached (no Crash mislabel), etc. + _ if matches!(state, SessionState::Idle | SessionState::Error { .. }) => (state.clone(), Vec::new()), + + // ---- User cancel (feature 004 S14/R14): fold to Idle terminal. ---- + // Placed BEFORE the Starting guard so a Stop during startup also + // resolves. Behavior-preserving (mirrors old ACP clean termination); + // NEVER Error{Crashed}. The manager's subsequent process-kill yields a + // Detached that I10 (now Idle) absorbs — so crash≠cancel holds by + // construction. Idempotent: a Cancel while already Idle/Error is + // absorbed by I10 above. + SessionEvent::Cancel => { + let epoch = anchor_epoch(state); + settle(state.clone(), SessionState::Idle, epoch) + } + + // ---- Startup-window crash (feature 004 S19/R18): a process that ---- + // exits while still Starting (0-frame exit — bad flag / bad session-id / + // bad mcp-config / unauthenticated / version too old) MUST resolve to + // Error{Crashed}, NOT be silently swallowed by the Starting guard below + // (which would hang until the idle-Timeout). This is the audit-found + // hole: a 0-frame startup crash never reaches Running, so the + // Running-arm Detached handling can't catch it. Placed BEFORE the + // generic Starting guard. exit==0 here is still abnormal (the process + // left before producing any result/turn), so it is also Crashed. + SessionEvent::Detached { .. } if matches!(state, SessionState::Starting) => settle( + state.clone(), + SessionState::Error { + reason: ErrorReason::Crashed, + }, + anchor_epoch(state), + ), + + // ---- Error TurnResult while Starting (feature 004 R16/3.9): a bad ---- + // `--resume` fails on the FIRST frame with `result{is_error:true}` (e.g. + // "No conversation found") while still Starting (TurnStarted seen, no + // stream frame yet). It MUST route to Error{Backend} so the message + // (the resume-failure cause) reaches the crash-resume self-heal — the + // generic Starting guard below would drop it, the exit-watcher would + // then emit Error{Crashed} (cause lost), and the self-heal that matches + // "No conversation found" would never fire → permanent resume wedge. + SessionEvent::TurnResult { + is_error: true, + api_error_status, + result_text, + // No epoch guard while Starting: a bad-`--resume` failure legitimately + // carries the live epoch, and the resume self-heal MUST see its cause. + epoch: _, + // `outcome` (007 §C2/O3) is additive richness the reducer never reads — + // routing stays on is_error. Ignored here. + outcome: _, + } if matches!(state, SessionState::Starting) => settle( + state.clone(), + SessionState::Error { + reason: ErrorReason::Backend { + api_error_status, + message: result_text, + }, + }, + anchor_epoch(state), + ), + + // ---- Events in Starting (pre-spawn window): only TurnStarted is ---- + // meaningful (handled above); anything else is ignored defensively + // (no frames arrive before the process is up). + _ if matches!(state, SessionState::Starting) => (state.clone(), Vec::new()), + + // ---- Running arms (state is Running for everything below) ---- + SessionEvent::MessageDelta { text, .. } => { + let mut to = state.clone(); + if !text.is_empty() + && let SessionState::Running { + saw_substantive_output, .. + } = &mut to + { + *saw_substantive_output = true; + } + // accumulator-only: external phase unchanged → no Transition. + (to, Vec::new()) + } + + // thinking/reasoning is NOT substantive (C5/I5): no state change. + SessionEvent::ThoughtDelta { .. } => (state.clone(), Vec::new()), + + // tool_use alone is not yet substantive; the COMPLETED tool (ToolResult) + // is. So ToolCall does not set the accumulator. + SessionEvent::ToolCall { .. } => (state.clone(), Vec::new()), + + // A completed tool_use = substantive output (C5/I5). ToolResult may + // arrive WITHOUT a preceding ToolCall (server-managed tools, RFC §3 + // ToolResult-informational law) — we treat it as informational, with no + // pairing assertion. + SessionEvent::ToolResult { .. } => { + let mut to = state.clone(); + if let SessionState::Running { + saw_substantive_output, .. + } = &mut to + { + *saw_substantive_output = true; + } + (to, Vec::new()) + } + + // Heartbeat: a backend liveness signal; the reducer does not change state + // on it (and there is no deadline to reset — no auto-timeout). + SessionEvent::Heartbeat => (state.clone(), Vec::new()), + + // Terminal result: route by is_error (C7/R10), NEVER by subtype. + SessionEvent::TurnResult { + is_error, + api_error_status, + result_text, + epoch: result_epoch, + // 007 §C2/O3: the reducer routes the success/error split on `is_error` + // alone. The ONE refinement (009 R1f): a REFUSAL is a clean turn + // completion that legitimately carries empty result text — it must + // NOT be misread as an EmptyTurn error in the OUTPUT-PRESENCE gate + // below. So we read `outcome` solely to recognize that one case. + outcome, + } => { + // Cross-turn guard (mirrors the TurnStarted guard at the top): a + // result whose epoch is STRICTLY OLDER than the current Running turn + // belongs to a prior (cancelled) turn whose trailing `result` claude + // flushed late — drop it so it can't settle THIS turn as Error. Use + // `<` not `<=`: the current turn's own result carries + // `epoch == since_epoch` and MUST settle. + // + // `epoch == 0` = UNSTAMPED (the 002 adapter is epoch-agnostic; a + // backend that never cancels mid-turn leaves it 0). An unstamped + // result is NEVER dropped — it settles the current turn as before. + // Only a result carrying a real, strictly-older turn id is stale. + if let SessionState::Running { since_epoch, .. } = state + && result_epoch != 0 + && result_epoch < *since_epoch + { + return (state.clone(), Vec::new()); + } + let saw = matches!( + state, + SessionState::Running { + saw_substantive_output: true, + .. + } + ); + let epoch = anchor_epoch(state); + // 009 R1f: a refusal is reported as a clean turn (is_error:false) with + // empty result text. It is a legitimate completion — the model + // declined — so the user must be able to send again (fold Idle, NOT + // Error{EmptyTurn} which would leave can_send stuck false on a + // perfectly recoverable turn). Only the empty-output gate consults it. + let refused = matches!( + outcome, + crate::event::TurnOutcome::Completed { + stop_reason: crate::event::StopReason::Refused { .. } + } + ); + let to = if is_error { + SessionState::Error { + reason: ErrorReason::Backend { + api_error_status, + message: result_text, + }, + } + } else if !saw && result_text.is_empty() && !refused { + // OUTPUT-PRESENCE (C5): is_error:false AND no substantive output + // (no non-empty text, no completed tool_use) AND empty result → + // EmptyTurn, NOT success. A refusal is exempt (handled as Idle). + SessionState::Error { + reason: ErrorReason::EmptyTurn, + } + } else { + SessionState::Idle + }; + settle(state.clone(), to, epoch) + } + + // Process-exit edge. drain-before-honor (I11) guarantees any pending + // TurnResult was applied first. Because S10 mandates TurnResult→terminal + // DIRECTLY (Idle/Error) and I10 absorbs a post-terminal Detached, by the + // time a Detached is stepped while STILL Running, no result was seen ⇒ + // `terminal_result_seen` reads false here. (§C 6.2 line 519 acknowledges + // this: "FollowResult → should not occur (still Running means no result was seen, a contradiction)".) + // crash_outcome is the pure-fn EXTRACTION of this branch. Its arms are + // pinned by `crash_outcome_arms` (all four input combos incl. the F46 + // clean-0→CleanNoResult distinction), and the WIRED Detached→Error + // mappings by `detached_without_result_is_crashed` (signal/non-zero→ + // Crashed), `detached_none_exit_treated_as_terminal_crash` (None→Crashed), + // and `detached_clean_exit_zero_while_running_is_empty_turn_not_crashed` + // (clean-0→EmptyTurn). So the arms are not dead at the contract level even + // though the live path only reaches Crashed/EmptyTurn-while-Running. + SessionEvent::Detached { exit, .. } => { + // G2 `redacted_summary` rides this event for the conversation layer + // (crash ErrorTip); the FSM routes on `exit` only (I10/D3 unchanged). + let seen = matches!( + state, + SessionState::Running { + terminal_result_seen: true, + .. + } + ); + let epoch = anchor_epoch(state); + match crash_outcome(seen, exit) { + Outcome::Crashed => settle( + state.clone(), + SessionState::Error { + reason: ErrorReason::Crashed, + }, + epoch, + ), + // F46: clean exit-0 with no result is an EMPTY turn, not a crash — + // distinct ErrorReason so the control plane's recovery disposition + // is correct (a clean early-EOF is not a SIGKILL). + Outcome::CleanNoResult => settle( + state.clone(), + SessionState::Error { + reason: ErrorReason::EmptyTurn, + }, + epoch, + ), + // Unreachable while Running in the wired path (see above); + // defensively a no-op so a future drain model that DOES record + // the flag on Running stays correct. + Outcome::FollowResult => (state.clone(), Vec::new()), + } + } + + // control-request: enter requires-action (+1). Crosses zero on the + // 0→1 edge → emit Transition (deadline scope / RA badge). 007 §6b b3: + // route to the counter matching `kind` (Tool→approval, Auth→auth). + SessionEvent::Permission { kind, .. } => { + let mut to = state.clone(); + let epoch = anchor_epoch(state); + if let SessionState::Running { requires_action, .. } = &mut to { + match kind { + crate::event::PermissionKind::Tool => { + requires_action.waiting_on_approval = requires_action.waiting_on_approval.saturating_add(1); + } + crate::event::PermissionKind::Auth => { + requires_action.waiting_on_auth = requires_action.waiting_on_auth.saturating_add(1); + } + } + } + settle(state.clone(), to, epoch) + } + + // resolve: -1 on the SAME counter the originating Permission incremented + // (kind echoed by the adapter, §6b b3). Only the WHOLE set (both counters) + // reaching zero leaves the requires-action sub-state (I7) — is_requires_action + // reads both, so external_phase/settle handle the zero-crossing uniformly. + SessionEvent::PermissionResolved { kind, .. } => { + let mut to = state.clone(); + let epoch = anchor_epoch(state); + if let SessionState::Running { requires_action, .. } = &mut to { + match kind { + crate::event::PermissionKind::Tool => { + requires_action.waiting_on_approval = requires_action.waiting_on_approval.saturating_sub(1); + } + crate::event::PermissionKind::Auth => { + requires_action.waiting_on_auth = requires_action.waiting_on_auth.saturating_sub(1); + } + } + } + settle(state.clone(), to, epoch) + } + + // Opaque escape hatch (I13): count or ignore only; never inspect + // tag/payload. P0 = ignore (no state change, no Transition). + SessionEvent::AdapterSpecific { .. } => (state.clone(), Vec::new()), + + // ================================================================== + // ADDITIVE no-op arms (007 §6a / §9.0). These variants are pure + // consumer / bracket / orchestration signals — the reducer's decision + // logic is UNCHANGED; each takes an explicit no-op arm (the mechanical Rust + // exhaustiveness requirement, NOT a behavior change). The ONE additive + // variant the reducer READS is SubagentUpdate (§6b b1, handled in P0b). + // ================================================================== + SessionEvent::PromptAccepted { .. } + | SessionEvent::UsageDelta { .. } + // LC-8a: Plan is a to-do SNAPSHOT — content within a Running turn, NOT a + // state (cross-protocol verified). The reducer never reads it; only the + // conversation layer projects it to the UI panel. Pure no-op here. + | SessionEvent::Plan { .. } + | SessionEvent::Provisioning { .. } + | SessionEvent::Rewound { .. } + | SessionEvent::ConfigChanged { .. } + // Async catalog discovery (claude initialize / codex model/list response). + // Pure UI surface — the conversation projects it to the model/mode picker; + // the FSM never reads a catalog. No-op here (like ConfigChanged). + | SessionEvent::CatalogUpdated { .. } + | SessionEvent::ItemStarted { .. } + | SessionEvent::ItemCompleted { .. } + // Live tool-output / turn-diff streams: pure display liveness within a + // Running turn (a tool streaming output / a diff updating is just progress). + // The FSM never reads them; only the conversation projects them to live panes. + | SessionEvent::ToolOutputDelta { .. } + | SessionEvent::TurnDiffUpdated { .. } + // An out-of-turn advisory (codex warning/deprecation/config). Not a turn + // signal, not requires-action — the conversation surfaces it; FSM no-op. + | SessionEvent::Notice { .. } + | SessionEvent::MessageFinalized(..) + | SessionEvent::Snapshot { .. } + | SessionEvent::Lagged { .. } + | SessionEvent::CheckpointList { .. } + // SessionInfo is a read-only query reply (context budget / cost) — never a + // turn signal. The conversation projects it; FSM no-op. + | SessionEvent::SessionInfo { .. } + // 009 R6b: SubagentDetail is the rich BACKGROUND-plane roster fill — read + // ONLY by the orchestrator's workflow_roster, never by the FSM. No-op here. + | SessionEvent::SubagentDetail { .. } + // Addendum 9: BackendBound is a pure pass-through for the conversation + // (persist backend_session_id). The reducer NEVER touches SessionState for + // it — it is not a turn signal, not a requires-action, not a roster update. + | SessionEvent::BackendBound { .. } + // 009 R6: BackendSuspended is FSM-invisible (the wake re-spawns on the + // same event_tx). The orchestrator clears the roster on it; the reducer + // does NOT move SessionState (suspend ≠ a turn boundary). + | SessionEvent::BackendSuspended => (state.clone(), Vec::new()), + + // ⭐ SubagentUpdate: the ONE §6b b1 reducer READ. Upsert into + // Running.subagents by `ref` (last-write-wins). This is the SOLE + // non-no-op additive arm — it adds a subagent-visibility capability + // dimension shared by ALL backends (NOT a per-backend branch), so claim + // B ("per-backend reducer 0-change") holds. Does NOT emit a Transition + // (roster changes don't move external phase / can_send); does NOT touch + // unlock. Only meaningful while Running (a subagent update outside a turn + // is dropped — terminal states are absorbed by I10 above, Starting by the + // guard). I14 prune of terminal entries is the orchestrator's job. + SessionEvent::SubagentUpdate { + r#ref, + label, + status, + parent_ref, + } => { + let mut to = state.clone(); + if let SessionState::Running { subagents, .. } = &mut to { + let next = crate::state::SubagentState { + r#ref, + label, + status, + parent_ref, + }; + match subagents.iter_mut().find(|s| s.r#ref == next.r#ref) { + // §11.4 terminal absorption (feature 009): once a subagent + // has reached a terminal status, a late/out-of-order + // non-terminal update (e.g. a lagged `progress` arriving + // after `Completed`) must NOT resurrect it back to active. + // Real ordering proven reachable (parent progress can arrive + // after child terminal). Any other transition is last-write-wins. + Some(slot) if slot.status.is_terminal() && !next.status.is_terminal() => {} + Some(slot) => *slot = next, // last-write-wins + None => subagents.push(next), // first sighting + } + } + // roster mutation only — external phase unchanged → no Transition. + (to, Vec::new()) + } + } +} + +/// Crash discriminator (C6/D3/I6): pure fn, 2 inputs. `None` exit ⇒ treat as +/// terminal exit. Only consulted while a turn is non-terminal (Running); +/// once terminal the `Detached` is absorbed by I10. +pub fn crash_outcome(terminal_result_seen: bool, exit: Option) -> Outcome { + // A result already decided the turn → follow it (the Detached is absorbed). + if terminal_result_seen { + return Outcome::FollowResult; + } + // No terminal result this turn. F46: the exit payload distinguishes a CLEAN + // early exit (code 0, no signal) from an abnormal crash (signal, non-zero + // code, or unknown). Conflating them fed the wrong ErrorReason — and thus the + // wrong recovery disposition — to the control plane. The reducer previously + // discarded `exit` entirely (`let _ = exit`). + match exit { + // Clean exit-0, no result → EmptyTurn-class (CleanNoResult), not Crashed. + Some(ExitStatusLite { + code: Some(0), + signal: None, + }) => Outcome::CleanNoResult, + // Signal, non-zero code, or unknown (None) → abnormal → Crashed. + _ => Outcome::Crashed, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- generators (§7.4): build canonical event sequences ---- + fn running(epoch: u64) -> SessionState { + SessionState::Running { + since_epoch: epoch, + saw_substantive_output: false, + terminal_result_seen: false, + requires_action: RequiresActionSet::default(), + subagents: Vec::new(), + } + } + + /// Drive a sequence of events from Idle, returning the final state. + fn drive(events: Vec) -> SessionState { + let mut s = SessionState::Idle; + for ev in events { + let (next, _) = step(&s, ev); + s = next; + } + s + } + + /// Builds a TurnResult for the CURRENT turn (epoch u64::MAX ⇒ never older + /// than any Running.since_epoch, so it always settles — the pre-epoch-guard + /// behavior these tests assume). The cross-turn-stale case is exercised + /// explicitly with a low epoch in the dedicated guard tests. + fn turn_result(is_error: bool, status: Option, text: &str) -> SessionEvent { + SessionEvent::TurnResult { + is_error, + api_error_status: status, + result_text: text.to_string(), + epoch: u64::MAX, + outcome: crate::event::TurnOutcome::default(), // additive (007 §C2); reducer ignores it + } + } + + // ---- equivalence classes (§7.3) ---- + + #[test] + fn turn_started_enters_running() { + let (s, t) = step(&SessionState::Idle, SessionEvent::TurnStarted { epoch: 1 }); + assert!(matches!(s, SessionState::Running { since_epoch: 1, .. })); + assert_eq!(t.len(), 1, "Idle→Running emits a Transition"); + } + + #[test] + fn happy_turn_with_text_goes_idle() { + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 1 }, + SessionEvent::MessageDelta { + item_id: "a".into(), + text: "hi".into(), + }, + turn_result(false, None, "hi"), + ]); + assert_eq!(s, SessionState::Idle); + } + + #[test] + fn tool_only_turn_is_success_not_empty() { + // no final text, only a completed tool — substantive (C5/I5). + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 1 }, + SessionEvent::ToolCall { + tool_use_id: "t1".into(), + name: "Write".into(), + subagent: crate::event::SubagentKind::Inline, + input: serde_json::Value::Null, + parent_tool_use_id: None, + }, + SessionEvent::ToolResult { + tool_use_id: "t1".into(), + is_error: false, + content: vec![], + parent_tool_use_id: None, + }, + turn_result(false, None, ""), + ]); + assert_eq!(s, SessionState::Idle, "tool-only turn must be Idle, not EmptyTurn"); + } + + #[test] + fn empty_turn_thinking_only_is_error() { + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 1 }, + SessionEvent::ThoughtDelta { + item_id: "a".into(), + text: "hmm".into(), + }, + turn_result(false, None, ""), + ]); + assert_eq!( + s, + SessionState::Error { + reason: ErrorReason::EmptyTurn + } + ); + } + + #[test] + fn refusal_with_empty_result_folds_idle_not_empty_turn() { + // 009 R1f: a refusal is is_error:false with empty result text — exactly + // the shape the OUTPUT-PRESENCE gate would mislabel EmptyTurn. But a + // refusal is a clean turn completion (the model declined), so the user + // must be able to send again: fold Idle (can_send recoverable), NOT + // Error{EmptyTurn}. The Refused marker rides outcome (007 §C2/O3). + let refused = SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: String::new(), + epoch: 0, + outcome: crate::event::TurnOutcome::Completed { + stop_reason: crate::event::StopReason::Refused { category: None }, + }, + }; + let s = drive(vec![SessionEvent::TurnStarted { epoch: 1 }, refused]); + assert_eq!( + s, + SessionState::Idle, + "a refusal (empty result, is_error:false) folds Idle so can_send recovers — NOT Error{{EmptyTurn}}" + ); + } + + #[test] + fn genuine_empty_turn_still_errors_when_outcome_is_not_refused() { + // Guard the R1f exemption is narrow: a TRULY empty turn (default outcome, + // no Refused marker) still folds to Error{EmptyTurn}. Only the Refused + // marker is exempt — not all empty results. + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 1 }, + turn_result(false, None, ""), + ]); + assert_eq!( + s, + SessionState::Error { + reason: ErrorReason::EmptyTurn + }, + "empty turn without a Refused marker is still EmptyTurn" + ); + } + + #[test] + fn backend_error_routes_by_is_error_not_subtype() { + // 400 / 401: is_error:true, result non-empty. MUST be Backend, not Idle. + for status in [400u16, 401] { + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 1 }, + SessionEvent::MessageDelta { + item_id: "a".into(), + text: "API Error".into(), + }, + turn_result(true, Some(status), "API Error ..."), + ]); + match s { + SessionState::Error { + reason: ErrorReason::Backend { api_error_status, .. }, + } => { + assert_eq!(api_error_status, Some(status)); + } + other => panic!("expected Backend{{{status}}}, got {other:?}"), + } + } + } + + #[test] + fn heartbeat_does_not_change_state() { + let before = running(1); + let (after, t) = step(&before, SessionEvent::Heartbeat); + assert_eq!(before, after); + assert!(t.is_empty()); + } + + #[test] + fn detached_without_result_is_crashed() { + let (s, _) = step( + &running(1), + SessionEvent::Detached { + exit: Some(ExitStatusLite { + code: Some(1), + signal: None, + }), + redacted_summary: None, + }, + ); + assert_eq!( + s, + SessionState::Error { + reason: ErrorReason::Crashed + } + ); + } + + #[test] + fn detached_none_exit_treated_as_terminal_crash() { + // None = exited-status-unknown, MUST be terminal exit not "still running". + let (s, _) = step( + &running(1), + SessionEvent::Detached { + exit: None, + redacted_summary: None, + }, + ); + assert_eq!( + s, + SessionState::Error { + reason: ErrorReason::Crashed + } + ); + } + + #[test] + fn detached_after_result_is_absorbed_not_crashed() { + // result first (→Idle), then a late Detached must be absorbed (I10). + let mut s = running(1); + let (next, _) = step(&s, turn_result(false, None, "done")); + s = next; + assert_eq!(s, SessionState::Idle); + let (s2, t) = step( + &s, + SessionEvent::Detached { + exit: Some(ExitStatusLite { + code: Some(0), + signal: None, + }), + redacted_summary: None, + }, + ); + assert_eq!(s2, SessionState::Idle, "late Detached absorbed, NOT re-Crashed"); + assert!(t.is_empty()); + } + + // ---- RequiresAction ref-count (I7) ---- + + #[test] + fn permission_enters_requires_action_and_resolve_returns() { + let (s1, t1) = step( + &running(1), + SessionEvent::Permission { + request_id: "r1".into(), + kind: crate::event::PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + ); + assert!(crate::state::is_requires_action(&s1)); + assert_eq!(t1.len(), 1, "0→1 crosses zero, emits Transition"); + + let (s2, t2) = step( + &s1, + SessionEvent::PermissionResolved { + request_id: "r1".into(), + kind: crate::event::PermissionKind::Tool, + }, + ); + assert!(!crate::state::is_requires_action(&s2), "back to plain Running"); + assert_eq!(t2.len(), 1, "1→0 crosses zero, emits Transition"); + } + + #[test] + fn resolving_one_of_two_does_not_unlock() { + let mut s = running(1); + for r in ["r1", "r2"] { + let (n, _) = step( + &s, + SessionEvent::Permission { + request_id: r.into(), + kind: crate::event::PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + ); + s = n; + } + let (s2, t) = step( + &s, + SessionEvent::PermissionResolved { + request_id: "r1".into(), + kind: crate::event::PermissionKind::Tool, + }, + ); + assert!( + crate::state::is_requires_action(&s2), + "count 2→1 stays in requires-action" + ); + assert!(t.is_empty(), "no zero-crossing → no Transition"); + } + + /// race-audit ss-8: a STRAY / DUPLICATE `PermissionResolved` (an orphan from a + /// reconnect, a hook-vs-user double-answer, an external resolver racing the + /// user) must not underflow the ref-count. `saturating_sub` floors it at 0: + /// the counter stays 0, the state stays plain Running (NOT requires-action), + /// and NO spurious Transition is emitted (no false zero-crossing → no bogus + /// deadline-scope flip / RA-badge toggle). A bare `-1` would wrap to u32::MAX + /// and wedge the session in requires-action forever (can_send stuck false). + #[test] + fn stray_or_duplicate_resolve_floors_at_zero_no_underflow() { + // (a) Resolve against a plain Running (counter already 0): orphan / late / + // reconnect Resolved with no matching Permission outstanding. + let (s, t) = step( + &running(1), + SessionEvent::PermissionResolved { + request_id: "orphan".into(), + kind: crate::event::PermissionKind::Tool, + }, + ); + assert!( + !crate::state::is_requires_action(&s), + "stray Resolved must NOT enter requires-action (no underflow to u32::MAX)" + ); + if let SessionState::Running { requires_action, .. } = &s { + assert_eq!( + requires_action.waiting_on_approval, 0, + "counter floors at 0, not wrapped" + ); + assert_eq!(requires_action.waiting_on_auth, 0); + } else { + panic!("must stay Running, got {s:?}"); + } + assert!(t.is_empty(), "no zero-crossing → no spurious Transition"); + + // (b) DOUBLE Resolved after a single Permission (over-resolve): 0→1 (RA), + // 1→0 (back to Running, the real crossing), then the SECOND Resolved + // must floor at 0 — not re-cross and not wrap. + let (s1, _) = step( + &running(1), + SessionEvent::Permission { + request_id: "r1".into(), + kind: crate::event::PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + ); + let (s2, _) = step( + &s1, + SessionEvent::PermissionResolved { + request_id: "r1".into(), + kind: crate::event::PermissionKind::Tool, + }, + ); + assert!( + !crate::state::is_requires_action(&s2), + "first resolve returns to Running" + ); + let (s3, t3) = step( + &s2, + SessionEvent::PermissionResolved { + request_id: "r1".into(), // duplicate answer for the same request + kind: crate::event::PermissionKind::Tool, + }, + ); + assert!( + !crate::state::is_requires_action(&s3), + "duplicate Resolved stays out of requires-action" + ); + if let SessionState::Running { requires_action, .. } = &s3 { + assert_eq!( + requires_action.waiting_on_approval, 0, + "stays floored at 0 on the duplicate" + ); + } else { + panic!("must stay Running, got {s3:?}"); + } + assert!( + t3.is_empty(), + "duplicate Resolved is not a zero-crossing → no Transition" + ); + + // (c) The floor is per-kind: a stray Auth Resolved does not corrupt the Tool + // counter (or vice versa). + let (s4, t4) = step( + &running(1), + SessionEvent::PermissionResolved { + request_id: "orphan-auth".into(), + kind: crate::event::PermissionKind::Auth, + }, + ); + if let SessionState::Running { requires_action, .. } = &s4 { + assert_eq!(requires_action.waiting_on_auth, 0, "auth counter floors at 0"); + assert_eq!(requires_action.waiting_on_approval, 0, "tool counter untouched"); + } else { + panic!("must stay Running, got {s4:?}"); + } + assert!(t4.is_empty()); + } + + // ---- terminal-absorbing law (I10) + epoch guard (I3) ---- + + #[test] + fn terminal_absorbs_late_result() { + let s = SessionState::Error { + reason: ErrorReason::EmptyTurn, + }; + let (s2, t) = step(&s, turn_result(false, None, "late")); + assert_eq!(s2, s, "Error absorbs a same-turn late result"); + assert!(t.is_empty()); + } + + #[test] + fn new_turn_started_supersedes_terminal() { + let s = SessionState::Error { + reason: ErrorReason::Crashed, + }; + let (s2, _) = step(&s, SessionEvent::TurnStarted { epoch: 2 }); + assert!(matches!(s2, SessionState::Running { since_epoch: 2, .. })); + } + + /// A Running carrying NON-default per-turn memory, so that the epoch guard's + /// effect is OBSERVABLE: the guard preserves the carry (returns state.clone), + /// whereas any guard-less / mis-compared rebuild resets it to all-defaults. + fn running_with_carry(epoch: u64) -> SessionState { + SessionState::Running { + since_epoch: epoch, + saw_substantive_output: true, // non-default: a rebuild would reset to false + terminal_result_seen: false, + requires_action: RequiresActionSet { + waiting_on_approval: 1, // non-default: a rebuild would reset to 0 + waiting_on_auth: 0, + }, + subagents: Vec::new(), + } + } + + #[test] + fn stale_turn_started_is_dropped_while_running() { + // epoch guard (I3): a TurnStarted NOT advancing past since_epoch is + // dropped — the Running carry is PRESERVED unchanged. The fixture carries + // non-default memory so deleting the guard (rebuild → all-defaults) makes + // this assertion fail. Mutation teeth (§7.6 #11): guard-delete, `<=`→`<`, + // and `<=`→`==` all flip a sub-case below. + let s = running_with_carry(5); + + // (a) duplicate epoch (==): guard drops it, carry preserved. + // Kills guard-delete AND `<=`→`<` (which would NOT drop epoch==5 → + // rebuild resets saw_substantive_output to false / count to 0). + let (s2, t) = step(&s, SessionEvent::TurnStarted { epoch: 5 }); + assert_eq!(s, s2, "duplicate epoch dropped, carry preserved"); + assert!(t.is_empty()); + + // (b) strictly-older epoch (<): guard drops it too, carry preserved. + // Kills `<=`→`==` (which would NOT drop epoch=3 since 3!=5 → rebuild + // resets the carry). + let (s3, t3) = step(&s, SessionEvent::TurnStarted { epoch: 3 }); + assert_eq!(s, s3, "older epoch dropped, carry preserved"); + assert!(t3.is_empty()); + + // (c) advancing epoch: NOT dropped — starts a fresh turn (carry reset is + // correct here, and since_epoch advances). + let (s4, _t4) = step(&s, SessionEvent::TurnStarted { epoch: 6 }); + match s4 { + SessionState::Running { + since_epoch, + saw_substantive_output, + requires_action, + .. + } => { + assert_eq!(since_epoch, 6, "advancing epoch starts a fresh turn"); + assert!(!saw_substantive_output, "fresh turn resets the accumulator"); + assert_eq!(requires_action.waiting_on_approval, 0, "fresh turn resets RA"); + } + other => panic!("expected Running{{6}}, got {other:?}"), + } + } + + // ---- illegal classes (§7.3 illegal-1 / illegal-2): never panic ---- + + #[test] + fn adapter_specific_is_ignored() { + let (s, t) = step( + &running(1), + SessionEvent::AdapterSpecific { + tag: "frobnicate".into(), + payload: serde_json::json!({"x": 1}), + }, + ); + assert_eq!(s, running(1)); + assert!(t.is_empty()); + } + + #[test] + fn backend_bound_is_a_reducer_noop() { + // Addendum 9: BackendBound is a pure conversation-side pass-through. It must + // NOT touch SessionState or emit a Transition — assert on a NON-default + // Running (carry preserved) so a mispredicate would flip an assertion. + let mut carry = running(7); + if let SessionState::Running { + saw_substantive_output, + requires_action, + .. + } = &mut carry + { + *saw_substantive_output = true; + requires_action.waiting_on_approval = 2; + } + for ev in [ + SessionEvent::BackendBound { + backend_session_id: Some("th-xyz".into()), + }, + SessionEvent::BackendBound { + backend_session_id: None, + }, + ] { + let (s, t) = step(&carry, ev); + assert_eq!(s, carry, "BackendBound leaves SessionState untouched"); + assert!(t.is_empty(), "BackendBound emits no Transition"); + } + } + + /// race/scenario audit: EVERY additive no-op signal (the merged arm at + /// reducer.rs §6a) must leave `SessionState` byte-for-byte unchanged AND emit + /// no `Transition`. The proptest only guarantees no-panic + determinism — it + /// does NOT assert no-op, so a bug routing one of these to a state-mutating + /// arm would slip through. This pins the no-op semantics for every consumer / + /// bracket / orchestration variant (same model as `backend_bound_is_a_reducer_noop`). + /// EXCLUDES: SubagentUpdate (the ONE READ arm), BackendBound + AdapterSpecific + /// (already have dedicated tests). Fed from a NON-DEFAULT Running carry so a + /// mis-route flips an assertion. + #[test] + fn additive_signals_are_reducer_noops() { + use crate::event::{ + CheckpointEntry, FinalizedMessage, ItemKind, ProvisioningPhase, TruncationInfo, TruncationKind, + }; + let mut carry = running(7); + if let SessionState::Running { + saw_substantive_output, + requires_action, + .. + } = &mut carry + { + *saw_substantive_output = true; + requires_action.waiting_on_approval = 2; + } + + let events = vec![ + SessionEvent::PromptAccepted { + client_msg_id: "m".into(), + }, + SessionEvent::UsageDelta { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + cost_usd: Some(0.5), + }, + // use the payload-carrying phase (stronger than the proptest's nullary ToolsReady) + SessionEvent::Provisioning { + phase: ProvisioningPhase::LoadFailed { reason: "x".into() }, + }, + SessionEvent::Rewound { to_turn: 3 }, + SessionEvent::ConfigChanged { + mode: Some("plan".into()), + model: Some("m".into()), + }, + SessionEvent::ToolOutputDelta { + item_id: "call_0".into(), + text: "line\n".into(), + }, + SessionEvent::TurnDiffUpdated { + diff: "diff --git a/x b/x".into(), + }, + SessionEvent::Notice { + level: crate::event::NoticeLevel::Warning, + message: "advisory".into(), + }, + SessionEvent::ItemStarted { + item_id: "i".into(), + kind: ItemKind::Tool, + }, + SessionEvent::ItemCompleted { + item_id: "i".into(), + truncation: Some(TruncationInfo { + kind: TruncationKind::MaxTokens, + partial_text: Some("p".into()), + }), + }, + SessionEvent::MessageFinalized(FinalizedMessage { + item_id: "i".into(), + kind: ItemKind::Text, + content: "c".into(), + truncation: None, + seq: 1, + }), + SessionEvent::Snapshot { + state_repr: "Idle".into(), + turn_gen: 2, + }, + SessionEvent::Lagged { skipped: 4 }, + // non-empty entries: pin that the Vec content does not matter + SessionEvent::CheckpointList { + entries: vec![CheckpointEntry { + id: "cp1".into(), + label: Some("first".into()), + turn_gen: Some(1), + }], + }, + SessionEvent::SessionInfo { + context_usage: None, + cost_text: Some("Total cost: $0".into()), + }, + // SubagentDetail: the rich background-plane fill (all fields None-able); + // reducer no-op (read only by the orchestrator's workflow_roster). + SessionEvent::SubagentDetail { + r#ref: "s".into(), + parent_ref: None, + label: None, + loop_state: None, + model: None, + tokens: None, + tool_calls: None, + last_tool_name: None, + }, + SessionEvent::BackendSuspended, + ]; + + for ev in events { + let label = format!("{ev:?}"); + let (s, t) = step(&carry, ev); + assert_eq!(s, carry, "{label} must leave SessionState unchanged"); + assert!(t.is_empty(), "{label} must emit no Transition"); + } + } + + /// Every SessionEvent EXCEPT TurnStarted, for constructing the terminal-absorb + /// enumeration. Keep in lockstep with the SessionEvent enum (the count assert in + /// `terminal_states_absorb_every_event_except_turn_started` is the tripwire). + fn all_events_except_turn_started() -> Vec { + use crate::event::{ + CheckpointEntry, ExitStatusLite, FinalizedMessage, ItemKind, PermissionKind, ProvisioningPhase, + SubagentKind, SubagentStatus, ToolResultContent, + }; + vec![ + SessionEvent::Cancel, + SessionEvent::Heartbeat, + SessionEvent::MessageDelta { + item_id: "i".into(), + text: "t".into(), + }, + SessionEvent::ThoughtDelta { + item_id: "i".into(), + text: "t".into(), + }, + SessionEvent::ToolCall { + tool_use_id: "t".into(), + name: "Bash".into(), + subagent: SubagentKind::Inline, + input: serde_json::Value::Null, + parent_tool_use_id: None, + }, + SessionEvent::ToolResult { + tool_use_id: "t".into(), + is_error: false, + content: vec![ToolResultContent::Text("out".into())], + parent_tool_use_id: None, + }, + turn_result(false, None, "done"), + turn_result(true, Some(401), "boom"), + SessionEvent::Detached { + exit: Some(ExitStatusLite { + code: Some(0), + signal: None, + }), + redacted_summary: None, + }, + SessionEvent::AdapterSpecific { + tag: "x".into(), + payload: serde_json::json!({}), + }, + SessionEvent::Plan { + entries: vec![], + explanation: None, + }, + SessionEvent::Permission { + request_id: "r".into(), + kind: PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + SessionEvent::PermissionResolved { + request_id: "r".into(), + kind: PermissionKind::Tool, + }, + SessionEvent::PromptAccepted { + client_msg_id: "m".into(), + }, + SessionEvent::UsageDelta { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + cost_usd: None, + }, + SessionEvent::Provisioning { + phase: ProvisioningPhase::ToolsWaiting, + }, + SessionEvent::Rewound { to_turn: 3 }, + SessionEvent::ConfigChanged { + mode: Some("plan".into()), + model: None, + }, + subagent_update("a1", SubagentStatus::Running, None), + SessionEvent::SubagentDetail { + r#ref: "s".into(), + parent_ref: None, + label: None, + loop_state: None, + model: None, + tokens: None, + tool_calls: None, + last_tool_name: None, + }, + SessionEvent::ToolOutputDelta { + item_id: "call_0".into(), + text: "line\n".into(), + }, + SessionEvent::TurnDiffUpdated { + diff: "diff --git a/x b/x".into(), + }, + SessionEvent::Notice { + level: crate::event::NoticeLevel::Warning, + message: "advisory".into(), + }, + SessionEvent::ItemStarted { + item_id: "i".into(), + kind: ItemKind::Tool, + }, + SessionEvent::ItemCompleted { + item_id: "i".into(), + truncation: None, + }, + SessionEvent::MessageFinalized(FinalizedMessage { + item_id: "i".into(), + kind: ItemKind::Text, + content: "c".into(), + truncation: None, + seq: 1, + }), + SessionEvent::CheckpointList { + entries: vec![CheckpointEntry { + id: "cp".into(), + label: None, + turn_gen: Some(1), + }], + }, + SessionEvent::SessionInfo { + context_usage: None, + cost_text: Some("Total cost: $0".into()), + }, + SessionEvent::Snapshot { + state_repr: "Idle".into(), + turn_gen: 2, + }, + SessionEvent::Lagged { skipped: 1 }, + SessionEvent::BackendBound { + backend_session_id: Some("b".into()), + }, + SessionEvent::BackendSuspended, + ] + } + + /// ENUMERATION INVARIANT (terminal-absorbing law I10). Once Idle or Error, the FSM + /// must IGNORE every event except TurnStarted — same state out, no Transition. The + /// existing tests spot-checked a handful (late TurnResult, late Detached, late + /// Cancel); this drives the WHOLE event set × every terminal state, so a future + /// arm that mishandles e.g. `Idle + Permission` (entering requires-action off a + /// settled turn) trips here instead of shipping. TurnStarted is the ONE documented + /// exception (it supersedes the terminal → a new turn) and is asserted separately. + #[test] + fn terminal_states_absorb_every_event_except_turn_started() { + let events = all_events_except_turn_started(); + // 32 SessionEvent variants total; minus TurnStarted = 31, but turn_result appears + // twice (success + error) → 32 rows. A new variant grows this and trips the assert. + assert_eq!( + events.len(), + 32, + "every non-TurnStarted SessionEvent variant must be in the absorb table (+ the TurnResult ok/err split)" + ); + + let terminals = [ + SessionState::Idle, + SessionState::Error { + reason: ErrorReason::Crashed, + }, + SessionState::Error { + reason: ErrorReason::EmptyTurn, + }, + SessionState::Error { + reason: ErrorReason::Backend { + api_error_status: Some(401), + message: "auth".into(), + }, + }, + ]; + + for terminal in &terminals { + for ev in all_events_except_turn_started() { + let label = format!("{terminal:?} + {ev:?}"); + let (s, t) = step(terminal, ev); + assert_eq!( + &s, terminal, + "[{label}] terminal must absorb the event (state unchanged)" + ); + assert!(t.is_empty(), "[{label}] terminal must emit no Transition"); + } + // TurnStarted is the exception: it supersedes the terminal into a fresh turn. + let (s, t) = step(terminal, SessionEvent::TurnStarted { epoch: 99 }); + assert!( + matches!(s, SessionState::Running { .. }), + "[{terminal:?} + TurnStarted] supersedes terminal → Running (not absorbed)" + ); + assert!(!t.is_empty(), "TurnStarted out of a terminal emits a Transition"); + } + let _ = events; + } + + #[test] + fn result_before_any_assistant_does_not_panic() { + // illegal-2: result with no prior substantive event → EmptyTurn, no panic. + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 1 }, + turn_result(false, None, ""), + ]); + assert_eq!( + s, + SessionState::Error { + reason: ErrorReason::EmptyTurn + } + ); + } + + // ---- gap-closers (cargo-mutants L2): epoch stamping, empty-delta, + // Starting-state, transition direction ---- + + #[test] + fn transition_stamps_running_epoch() { + // anchor_epoch must return the Running since_epoch, and TurnResult's + // Transition must carry it (kills anchor_epoch->0/1 and delete-arm). + let (_s, t) = step(&running(7), turn_result(false, None, "ok")); + assert_eq!(t.len(), 1); + assert_eq!(t[0].epoch, 7, "Transition.epoch must equal Running.since_epoch"); + assert_eq!(t[0].to, SessionState::Idle); + assert_eq!(t[0].from, running(7)); + } + + #[test] + fn empty_message_delta_does_not_set_substantive() { + // kills `delete !` in MessageDelta: an EMPTY text must NOT count as output, + // so a following empty result must be EmptyTurn (not Idle). + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 1 }, + SessionEvent::MessageDelta { + item_id: "a".into(), + text: String::new(), + }, + turn_result(false, None, ""), + ]); + assert_eq!( + s, + SessionState::Error { + reason: ErrorReason::EmptyTurn + }, + "empty MessageDelta is not substantive output" + ); + } + + #[test] + fn nonempty_message_delta_then_empty_result_is_idle() { + // complement: a non-empty delta DOES make an empty-result turn succeed. + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 1 }, + SessionEvent::MessageDelta { + item_id: "a".into(), + text: "x".into(), + }, + turn_result(false, None, ""), + ]); + assert_eq!(s, SessionState::Idle); + } + + #[test] + fn realtime_end_turn_folds_idle_then_lagged_result_is_absorbed() { + // 009 R5 end-to-end: in --include-partial-messages mode the adapter maps + // message_delta{end_turn} → a real-time TurnResult (empty text), folding + // Idle the moment the reply finishes — saw_substantive_output was set by + // the preceding delta so OUTPUT-PRESENCE folds Idle, not EmptyTurn. The + // LATER lagged `result` frame (also a TurnResult) must be harmlessly + // absorbed by I10 (already terminal), NOT re-fold or error. + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 1 }, + SessionEvent::MessageDelta { + item_id: "a".into(), + text: "the answer".into(), + }, + // real-time end_turn (adapter's parse_stream_event output): empty text. + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: String::new(), + epoch: 0, + outcome: crate::event::TurnOutcome::Completed { + stop_reason: crate::event::StopReason::EndTurn, + }, + }, + ]); + assert_eq!( + s, + SessionState::Idle, + "real-time end_turn folds Idle (saw output → not EmptyTurn)" + ); + + // The lagged result lands later — I10 absorbs it, stays Idle. + let (s2, t2) = step(&s, turn_result(false, None, "the answer")); + assert_eq!(s2, SessionState::Idle, "lagged result absorbed by I10, no re-fold"); + assert!(t2.is_empty(), "absorbed terminal emits no Transition"); + } + + #[test] + fn startup_window_detached_is_crashed_not_swallowed() { + // S19/R18 (T21 tooth): a process that exits while still Starting (0-frame + // startup crash) MUST resolve to Error{Crashed} and emit a Transition so + // the UI unlocks immediately — NOT be swallowed by the Starting guard + // (which would hang to the idle-Timeout). Covers exit≠0, exit==0, and + // unknown (None) — all abnormal pre-result exits. + for exit in [ + Some(ExitStatusLite { + code: Some(1), + signal: None, + }), + Some(ExitStatusLite { + code: Some(0), + signal: None, + }), + None, + ] { + let (s, t) = step( + &SessionState::Starting, + SessionEvent::Detached { + exit, + redacted_summary: None, + }, + ); + assert_eq!( + s, + SessionState::Error { + reason: ErrorReason::Crashed + }, + "Starting + Detached({exit:?}) must be Error{{Crashed}}" + ); + assert_eq!(t.len(), 1, "Starting→Error emits a Transition (immediate unlock)"); + } + } + + #[test] + fn error_result_while_starting_routes_to_backend_not_dropped() { + // R16/3.9: a bad --resume fails on the first frame with + // result{is_error:true} while still Starting. It MUST become + // Error{Backend{message}} (so the crash-resume self-heal can match the + // cause), NOT be dropped by the Starting guard. + let (s, t) = step( + &SessionState::Starting, + turn_result(true, None, "No conversation found with session ID: abc"), + ); + match s { + SessionState::Error { + reason: ErrorReason::Backend { message, .. }, + } => assert!(message.contains("No conversation found")), + other => panic!("expected Error{{Backend}}, got {other:?}"), + } + assert_eq!( + t.len(), + 1, + "Starting→Error emits a Transition (unlock + self-heal trigger)" + ); + } + + #[test] + fn success_result_while_starting_is_still_ignored() { + // A non-error result while Starting is defensive-ignored (shouldn't + // happen — a real turn goes Starting→Running first). + let (s, t) = step(&SessionState::Starting, turn_result(false, None, "x")); + assert_eq!(s, SessionState::Starting); + assert!(t.is_empty()); + } + + #[test] + fn events_in_starting_are_ignored_except_turn_started() { + // kills `Starting guard -> false`: a data event while Starting must be a + // no-op (NOT fall through into the Running arms, which would mishandle it). + let (s, t) = step( + &SessionState::Starting, + SessionEvent::MessageDelta { + item_id: "a".into(), + text: "hi".into(), + }, + ); + assert_eq!(s, SessionState::Starting); + assert!(t.is_empty()); + // and a heartbeat / result while Starting is also ignored + let (s2, _) = step(&SessionState::Starting, turn_result(false, None, "x")); + assert_eq!(s2, SessionState::Starting); + } + + #[test] + fn requires_action_transition_only_on_zero_crossing() { + // kills `> with ==` in external_phase: going 1->2 must NOT emit a + // transition (still RequiresAction), and the phase must be RequiresAction + // for any count>0 (not just ==0). + let (s1, _) = step( + &running(1), + SessionEvent::Permission { + request_id: "a".into(), + kind: crate::event::PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + ); + let (s2, t2) = step( + &s1, + SessionEvent::Permission { + request_id: "b".into(), + kind: crate::event::PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + ); + assert!(crate::state::is_requires_action(&s2)); + assert!(t2.is_empty(), "1->2 stays RequiresAction, no Transition"); + } + + // ---- crash_outcome pure fn (I6) ---- + + #[test] + fn crash_outcome_arms() { + assert_eq!( + crash_outcome( + false, + Some(ExitStatusLite { + code: Some(1), + signal: None + }) + ), + Outcome::Crashed + ); + assert_eq!( + crash_outcome( + true, + Some(ExitStatusLite { + code: Some(0), + signal: None + }) + ), + Outcome::FollowResult + ); + assert_eq!(crash_outcome(false, None), Outcome::Crashed); + // F46 distinction (the WHOLE point of this pure fn): a clean exit-0 with + // NO result seen is CleanNoResult, NOT Crashed. Without this assertion a + // `<`→`_` / arm-reorder mutation collapsing CleanNoResult into Crashed + // would feed the wrong recovery disposition to the control plane and pass + // every other test (race-audit red-09). + assert_eq!( + crash_outcome( + false, + Some(ExitStatusLite { + code: Some(0), + signal: None + }) + ), + Outcome::CleanNoResult, + "clean exit-0 with no result is an EMPTY turn, not a crash" + ); + // A signal is abnormal → Crashed, never CleanNoResult. + assert_eq!( + crash_outcome( + false, + Some(ExitStatusLite { + code: None, + signal: Some(9) + }) + ), + Outcome::Crashed, + "a killed process (signal) is Crashed, not CleanNoResult" + ); + } + + #[test] + fn detached_clean_exit_zero_while_running_is_empty_turn_not_crashed() { + // The WIRED counterpart to crash_outcome_arms' CleanNoResult case: a + // process that exits cleanly (code:0, no signal) while STILL Running with + // no terminal result seen folds to Error{EmptyTurn} — a distinct recovery + // disposition from a SIGKILL crash. `running(1)` carries + // terminal_result_seen:false, so this exercises the CleanNoResult arm of + // the Detached match, not the I10 post-terminal absorption (race-audit + // red-09; pairs with detached_without_result_is_crashed for the signal arm). + let (s, t) = step( + &running(1), + SessionEvent::Detached { + exit: Some(ExitStatusLite { + code: Some(0), + signal: None, + }), + redacted_summary: None, + }, + ); + assert_eq!( + s, + SessionState::Error { + reason: ErrorReason::EmptyTurn + }, + "clean exit-0 while Running (no result) → Error{{EmptyTurn}}, NOT Error{{Crashed}}" + ); + assert_eq!(t.len(), 1, "the terminal transition is emitted (unlocks can_send)"); + } + + // ---- Feature 004 S14/R14: user cancel folds to Idle (NOT Crashed) ---- + + #[test] + fn cancel_in_running_folds_to_idle() { + // T15: Running + Cancel → Idle terminal (behavior-preserving), emits a + // Transition so the UI unlocks immediately (not after an idle-timeout). + let (s, t) = step(&running(1), SessionEvent::Cancel); + assert_eq!(s, SessionState::Idle, "cancel folds to Idle, never Error"); + assert_eq!( + t.len(), + 1, + "Running→Idle emits exactly one Transition (immediate unlock)" + ); + assert_eq!(t[0].to, SessionState::Idle); + } + + #[test] + fn cancel_during_startup_also_resolves_to_idle() { + // A Stop clicked while still Starting (process not yet up) must resolve + // too — the Cancel arm sits before the Starting guard. + let (s, _) = step(&SessionState::Starting, SessionEvent::Cancel); + assert_eq!(s, SessionState::Idle); + } + + #[test] + fn cancel_then_interrupt_error_result_is_absorbed_not_backend_error() { + // F1 interrupt-on-cancel: cancel() writes a CLI interrupt, then folds + // Running→Idle. claude's interrupt terminal is a + // `result{subtype:"error_during_execution",is_error:true}` — the SAME + // shape a bad-resume produces. It arrives AFTER the Cancel (Idle) and + // MUST be absorbed by I10, NOT become Error{Backend} — otherwise the + // resume-failure self-heal (which matches "error_during_execution") would + // misfire and wrongly clear a healthy session id on every user cancel. + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 1 }, + SessionEvent::Cancel, // → Idle + SessionEvent::TurnResult { + is_error: true, + api_error_status: None, + result_text: "error_during_execution".into(), + epoch: 1, // the cancelled turn's own epoch; absorbed by I10 while Idle + outcome: crate::event::TurnOutcome::default(), + }, + ]); + assert_eq!( + s, + SessionState::Idle, + "the interrupt's error_during_execution terminal is absorbed (Idle), NEVER Error{{Backend}} — \ + so a user cancel never misfires the resume-failure self-heal" + ); + } + + #[test] + fn stale_turn_result_dropped_while_running_new_turn() { + // PRODUCTION RACE (conv 0afe571b): user cancels turn 1, then RESENDS + // ~2.2s later (turn 2). claude flushes the INTERRUPT's trailing + // `result{is_error,error_during_execution}` ~2.6s after the interrupt — + // i.e. AFTER turn 2 is already Running. That stale (turn-1) result must + // be DROPPED, not settle turn 2 as Error (which surfaced as a bogus + // UNKNOWN_UPSTREAM_ERROR and misfired the resume self-heal). The epoch + // guard (epoch < since_epoch) makes this impossible by construction — + // the 500ms interrupt-drain barrier could not (claude took 2.6s). + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 1 }, // turn 1 + SessionEvent::Cancel, // → Idle (cancel folds first) + SessionEvent::TurnStarted { epoch: 2 }, // turn 2 (the resend) → Running{since_epoch:2} + // turn 1's late trailing interrupt result, arriving during turn 2: + SessionEvent::TurnResult { + is_error: true, + api_error_status: None, + result_text: "error_during_execution".into(), + epoch: 1, // STALE: belongs to the cancelled turn + outcome: crate::event::TurnOutcome::default(), + }, + ]); + assert!( + matches!(s, SessionState::Running { since_epoch: 2, .. }), + "a stale (turn-1) trailing result must be DROPPED while Running turn 2, \ + leaving turn 2 still Running — got {s:?}" + ); + } + + #[test] + fn current_turn_result_with_equal_epoch_settles() { + // The guard uses `<` not `<=`: the CURRENT turn's own result carries + // epoch == since_epoch and MUST settle (a `<`→`<=` mutation would wrongly + // drop every real result). + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 5 }, + SessionEvent::MessageDelta { + item_id: "m1".into(), + text: "hi".into(), + }, + SessionEvent::TurnResult { + is_error: false, + api_error_status: None, + result_text: "done".into(), + epoch: 5, // current turn + outcome: crate::event::TurnOutcome::default(), + }, + ]); + assert_eq!( + s, + SessionState::Idle, + "the current turn's own result (epoch==since_epoch) settles to Idle" + ); + } + + #[test] + fn older_epoch_error_result_dropped_preserves_running() { + // A strictly-older error result is dropped and the Running carry + // (saw_substantive_output) is preserved — no transition emitted. + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 5 }, + SessionEvent::ToolResult { + tool_use_id: "t1".into(), + is_error: false, + content: vec![], + parent_tool_use_id: None, + }, // substantive output seen + SessionEvent::TurnResult { + is_error: true, + api_error_status: Some(500), + result_text: "error_during_execution".into(), + epoch: 3, // stale + outcome: crate::event::TurnOutcome::default(), + }, + ]); + assert!( + matches!( + s, + SessionState::Running { + since_epoch: 5, + saw_substantive_output: true, + .. + } + ), + "stale error result dropped; Running carry preserved — got {s:?}" + ); + } + + #[test] + fn cancel_then_kill_detached_is_absorbed_not_crashed() { + // T15 tooth② (crash≠cancel): after Cancel→Idle, the manager kills the process; + // the resulting Detached arrives while ALREADY Idle and is absorbed by + // I10 — it MUST NOT be reclassified as Error{Crashed}. + let s = drive(vec![ + SessionEvent::TurnStarted { epoch: 1 }, + SessionEvent::Cancel, + SessionEvent::Detached { + exit: Some(ExitStatusLite { + code: None, + signal: Some(9), // SIGKILL from the cancel-kill + }), + redacted_summary: None, + }, + ]); + assert_eq!( + s, + SessionState::Idle, + "cancel-kill Detached is absorbed (Idle), never mislabeled Error{{Crashed}}" + ); + } + + #[test] + fn cancel_is_idempotent_when_already_terminal() { + // A Cancel after the turn already ended (Idle) is absorbed by I10. + let (s, t) = step(&SessionState::Idle, SessionEvent::Cancel); + assert_eq!(s, SessionState::Idle); + assert!(t.is_empty(), "no phase change → no Transition"); + } + + // ====================================================================== + // 007-P0b: the TWO intentional reducer changes (§6b / §C3.3 verification). + // ====================================================================== + use crate::event::{PermissionKind, SubagentStatus}; + use crate::state::{can_send_message, is_requires_action}; + + fn subagent_update(r: &str, status: SubagentStatus, parent: Option<&str>) -> SessionEvent { + SessionEvent::SubagentUpdate { + r#ref: r.into(), + label: None, + status, + parent_ref: parent.map(Into::into), + } + } + + fn subagents_of(s: &SessionState) -> Vec { + match s { + SessionState::Running { subagents, .. } => subagents.clone(), + _ => Vec::new(), + } + } + + #[test] + fn subagent_update_inserts_then_upserts_by_ref() { + // §6b b1 / V2a: first sighting inserts; second sighting for the same ref + // is last-write-wins (NOT a duplicate). No Transition (roster ≠ phase). + let (s1, t1) = step(&running(1), subagent_update("a1", SubagentStatus::Running, None)); + assert_eq!(subagents_of(&s1).len(), 1); + assert!(t1.is_empty(), "roster change emits no Transition"); + + let (s2, _) = step(&s1, subagent_update("a1", SubagentStatus::Completed, None)); + let roster = subagents_of(&s2); + assert_eq!(roster.len(), 1, "same ref upserts, does not duplicate"); + assert_eq!(roster[0].status, SubagentStatus::Completed, "last-write-wins"); + } + + // ── Feature 009 R1b / §11.4 terminal absorption ────────────────────────── + + #[test] + fn subagent_terminal_not_resurrected_by_late_nonterminal() { + // A subagent reaches Completed; a LAGGED/out-of-order non-terminal + // update (`Running`) arrives afterward. It must NOT flip the slot back + // to active — otherwise a finished subagent's spinner re-ignites and + // has_foreground_activity wrongly reports true. (Real ordering: a + // parent's `progress` can arrive after a child's terminal.) + let (s1, _) = step(&running(1), subagent_update("a1", SubagentStatus::Running, None)); + let (s2, _) = step(&s1, subagent_update("a1", SubagentStatus::Completed, None)); + let (s3, t3) = step(&s2, subagent_update("a1", SubagentStatus::Running, None)); + let roster = subagents_of(&s3); + assert_eq!(roster.len(), 1, "no duplicate"); + assert_eq!( + roster[0].status, + SubagentStatus::Completed, + "terminal absorbed the late non-terminal update (NOT resurrected to Running)" + ); + assert!(t3.is_empty(), "absorbed update emits no Transition"); + } + + #[test] + fn subagent_terminal_absorption_holds_for_all_terminal_states() { + // Every terminal status rejects a subsequent non-terminal update. + for term in [ + SubagentStatus::Completed, + SubagentStatus::Errored, + SubagentStatus::Shutdown, + SubagentStatus::Interrupted, + ] { + let (s1, _) = step(&running(1), subagent_update("a1", term, None)); + for late in [SubagentStatus::PendingInit, SubagentStatus::Running] { + let (s2, _) = step(&s1, subagent_update("a1", late, None)); + assert_eq!( + subagents_of(&s2)[0].status, + term, + "{term:?} must not be resurrected by a late {late:?}" + ); + } + } + } + + #[test] + fn subagent_terminal_to_terminal_is_still_last_write_wins() { + // Absorption only blocks terminal→non-terminal. A terminal→terminal + // correction (e.g. Completed then a corrected Errored) still applies, + // so the final outcome is not frozen on the first terminal sighting. + let (s1, _) = step(&running(1), subagent_update("a1", SubagentStatus::Completed, None)); + let (s2, _) = step(&s1, subagent_update("a1", SubagentStatus::Errored, None)); + assert_eq!( + subagents_of(&s2)[0].status, + SubagentStatus::Errored, + "terminal→terminal still LWW" + ); + } + + #[test] + fn subagent_multi_level_parent_ref_preserved() { + // §9.13: flat Vec + parent_ref edges. A child points at its parent's ref. + let mut s = running(1); + for ev in [ + subagent_update("root", SubagentStatus::Running, None), + subagent_update("child", SubagentStatus::Running, Some("root")), + ] { + let (n, _) = step(&s, ev); + s = n; + } + let roster = subagents_of(&s); + assert_eq!(roster.len(), 2); + let child = roster.iter().find(|x| x.r#ref == "child").unwrap(); + assert_eq!(child.parent_ref.as_deref(), Some("root"), "multi-level edge preserved"); + } + + #[test] + fn subagent_update_does_not_change_unlock_or_phase() { + // §9.12 M-12: subagents NEVER affect can_send / requires-action. + let (s, _) = step(&running(1), subagent_update("a1", SubagentStatus::Errored, None)); + assert!(!can_send_message(&s), "roster does not unlock"); + assert!(!is_requires_action(&s), "roster does not enter requires-action"); + assert!(matches!(s, SessionState::Running { .. }), "stays plain Running"); + } + + #[test] + fn subagent_update_outside_running_is_dropped() { + // Terminal states absorb it (I10); Idle is unchanged. + let (s, t) = step(&SessionState::Idle, subagent_update("a", SubagentStatus::Running, None)); + assert_eq!(s, SessionState::Idle); + assert!(t.is_empty()); + } + + #[test] + fn auth_permission_uses_separate_counter() { + // §6b b3 / V3a: Permission{kind:Auth} → waiting_on_auth (NOT approval), + // 0→1 crosses zero → Transition to requires-action. + let (s, t) = step( + &running(1), + SessionEvent::Permission { + request_id: "auth-1".into(), + kind: PermissionKind::Auth, + metadata: None, + tool_name: None, + input: None, + }, + ); + match &s { + SessionState::Running { requires_action, .. } => { + assert_eq!(requires_action.waiting_on_auth, 1, "auth counter incremented"); + assert_eq!(requires_action.waiting_on_approval, 0, "approval counter untouched"); + } + other => panic!("expected Running, got {other:?}"), + } + assert!(is_requires_action(&s)); + assert_eq!(t.len(), 1, "0→1 auth crosses zero, emits Transition"); + } + + #[test] + fn auth_resolve_returns_to_running() { + // V3b: Auth challenge then resolve → back to plain Running. + let (s1, _) = step( + &running(1), + SessionEvent::Permission { + request_id: "auth-1".into(), + kind: PermissionKind::Auth, + metadata: None, + tool_name: None, + input: None, + }, + ); + let (s2, t2) = step( + &s1, + SessionEvent::PermissionResolved { + request_id: "auth-1".into(), + kind: PermissionKind::Auth, + }, + ); + assert!(!is_requires_action(&s2), "auth resolved → plain Running"); + assert_eq!(t2.len(), 1, "1→0 auth crosses zero, emits Transition"); + } + + #[test] + fn tool_and_auth_counters_are_independent() { + // V3c: a tool approval AND an auth challenge pending; resolving the auth + // one does NOT unlock (the tool one still holds requires-action). + let mut s = running(1); + for ev in [ + SessionEvent::Permission { + request_id: "tool-1".into(), + kind: PermissionKind::Tool, + metadata: None, + tool_name: None, + input: None, + }, + SessionEvent::Permission { + request_id: "auth-1".into(), + kind: PermissionKind::Auth, + metadata: None, + tool_name: None, + input: None, + }, + ] { + let (n, _) = step(&s, ev); + s = n; + } + let (s2, t) = step( + &s, + SessionEvent::PermissionResolved { + request_id: "auth-1".into(), + kind: PermissionKind::Auth, + }, + ); + match &s2 { + SessionState::Running { requires_action, .. } => { + assert_eq!(requires_action.waiting_on_auth, 0, "auth resolved"); + assert_eq!(requires_action.waiting_on_approval, 1, "tool still pending"); + } + other => panic!("expected Running, got {other:?}"), + } + assert!(is_requires_action(&s2), "tool approval still holds requires-action"); + assert!( + t.is_empty(), + "auth 1→0 but set not empty (tool still 1) → no phase change" + ); + } + + #[test] + fn can_send_independent_of_both_counters_and_roster() { + // §C3.3 C8d: unlock = Idle only, never affected by auth/approval/subagents. + let mut s = running(1); + for ev in [ + subagent_update("a1", SubagentStatus::Running, None), + SessionEvent::Permission { + request_id: "auth-1".into(), + kind: PermissionKind::Auth, + metadata: None, + tool_name: None, + input: None, + }, + ] { + let (n, _) = step(&s, ev); + s = n; + } + assert!(!can_send_message(&s), "Running with auth + roster never unlocks"); + } +} + +/// 009 R3 / §C.0: machine-checked totality of the pure reducer. `step()` is a +/// total function over the ABSTRACT projection domain — the design repeatedly +/// claims "the 4-variant FSM is exhaustively enumerable", and this turns that +/// claim from prose into a CI invariant. The literal domain is ℵ₀ (RequiresAction +/// counters, unbounded subagents Vec, serde Values), so we sample the abstract +/// classes that the derivations actually read: ExternalPhase × ra-class × +/// subagent-active × {is_error, epoch-relation, …} crossed with one representative +/// of EVERY SessionEvent variant. Two properties: (P1) step never panics over the +/// full cross-product; (P2) step is deterministic (same input → identical output), +/// which would catch a non-deterministic or hidden-state regression. A guarded +/// `_ if` arm that silently swallows an event it should handle is caught by the +/// equivalence-class tests above; this pins that no input combination panics. +#[cfg(test)] +mod proptest_totality { + use super::*; + use crate::event::{ + ExitStatusLite, FinalizedMessage, ItemKind, PermissionKind, ProvisioningPhase, StopReason, SubagentStatus, + TurnOutcome, + }; + use crate::state::{ErrorReason, RequiresActionSet, SessionState, SubagentState}; + use proptest::prelude::*; + + /// Representative states spanning every abstract projection class. + fn any_state() -> impl Strategy { + prop_oneof![ + Just(SessionState::Idle), + Just(SessionState::Starting), + // Running × ra-class {0,1,2} × auth {0,1} × subagent {none, active, terminal}. + (0u32..3, 0u32..2, 0u64..3, prop::option::of(any_substatus())).prop_map(|(appr, auth, epoch, sub)| { + SessionState::Running { + since_epoch: epoch, + saw_substantive_output: epoch % 2 == 0, + terminal_result_seen: false, + requires_action: RequiresActionSet { + waiting_on_approval: appr, + waiting_on_auth: auth, + }, + subagents: sub + .map(|st| { + vec![SubagentState { + r#ref: "s".into(), + label: None, + status: st, + parent_ref: None, + }] + }) + .unwrap_or_default(), + } + }), + Just(SessionState::Error { + reason: ErrorReason::Crashed + }), + Just(SessionState::Error { + reason: ErrorReason::EmptyTurn + }), + Just(SessionState::Error { + reason: ErrorReason::Backend { + api_error_status: Some(400), + message: "e".into() + } + }), + ] + } + + fn any_substatus() -> impl Strategy { + prop_oneof![ + Just(SubagentStatus::PendingInit), + Just(SubagentStatus::Running), + Just(SubagentStatus::Interrupted), + Just(SubagentStatus::Completed), + Just(SubagentStatus::Errored), + Just(SubagentStatus::Shutdown), + ] + } + + /// One representative per EVERY SessionEvent variant (bounded payloads — the + /// reducer's behavior depends on the abstract shape, not string contents). + fn any_event() -> impl Strategy { + prop_oneof![ + (0u64..3).prop_map(|e| SessionEvent::TurnStarted { epoch: e }), + Just(SessionEvent::Cancel), + Just(SessionEvent::MessageDelta { + item_id: "i".into(), + text: "t".into() + }), + Just(SessionEvent::ThoughtDelta { + item_id: "i".into(), + text: "t".into() + }), + Just(SessionEvent::ToolCall { + tool_use_id: "tu".into(), + name: "n".into(), + subagent: Default::default(), + input: serde_json::Value::Null, + parent_tool_use_id: None, + }), + prop_oneof![ + Just(SessionEvent::ToolResult { + tool_use_id: "tu".into(), + is_error: false, + content: vec![], + parent_tool_use_id: None, + }), + Just(SessionEvent::ToolResult { + tool_use_id: "tu".into(), + is_error: true, + content: vec![], + parent_tool_use_id: None, + }), + ], + Just(SessionEvent::Heartbeat), + // TurnResult × is_error × epoch × outcome{EndTurn, Refused, Cancelled}. + (any::(), 0u64..3, 0u32..3).prop_map(|(is_error, epoch, oc)| SessionEvent::TurnResult { + is_error, + api_error_status: if is_error { Some(400) } else { None }, + result_text: if is_error { "e".into() } else { String::new() }, + epoch, + outcome: match oc { + 0 => TurnOutcome::default(), + 1 => TurnOutcome::Completed { + stop_reason: StopReason::Refused { category: None } + }, + _ => TurnOutcome::Completed { + stop_reason: StopReason::EndTurn + }, + }, + }), + prop::option::of(Just(ExitStatusLite { + code: None, + signal: Some(9) + })) + .prop_map(|exit| SessionEvent::Detached { + exit, + redacted_summary: None, + }), + Just(SessionEvent::AdapterSpecific { + tag: "x".into(), + payload: serde_json::Value::Null + }), + prop_oneof![Just(PermissionKind::Tool), Just(PermissionKind::Auth)].prop_map(|kind| { + SessionEvent::Permission { + request_id: "r".into(), + kind, + metadata: None, + tool_name: None, + input: None, + } + }), + prop_oneof![Just(PermissionKind::Tool), Just(PermissionKind::Auth)].prop_map(|kind| { + SessionEvent::PermissionResolved { + request_id: "r".into(), + kind, + } + }), + Just(SessionEvent::PromptAccepted { + client_msg_id: "m".into() + }), + Just(SessionEvent::UsageDelta { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + cost_usd: None, + }), + Just(SessionEvent::Provisioning { + phase: ProvisioningPhase::ToolsReady + }), + (0u64..3).prop_map(|t| SessionEvent::Rewound { to_turn: t }), + Just(SessionEvent::ConfigChanged { + mode: Some("plan".into()), + model: None + }), + any_substatus().prop_map(|status| SessionEvent::SubagentUpdate { + r#ref: "s".into(), + label: None, + status, + parent_ref: None, + }), + Just(SessionEvent::ItemStarted { + item_id: "i".into(), + kind: ItemKind::Text + }), + Just(SessionEvent::ItemCompleted { + item_id: "i".into(), + truncation: None + }), + Just(SessionEvent::MessageFinalized(FinalizedMessage { + item_id: "i".into(), + kind: ItemKind::Text, + content: "c".into(), + truncation: None, + seq: 0, + })), + (0u64..3).prop_map(|g| SessionEvent::Snapshot { + state_repr: "Idle".into(), + turn_gen: g + }), + (0u64..5).prop_map(|n| SessionEvent::Lagged { skipped: n }), + Just(SessionEvent::CheckpointList { entries: Vec::new() }), + Just(SessionEvent::BackendBound { + backend_session_id: Some("b".into()) + }), + // audit: these two were ABSENT from the generator → not even the + // no-panic/determinism sweep touched them. Add so totality is complete. + Just(SessionEvent::BackendSuspended), + Just(SessionEvent::SubagentDetail { + r#ref: "s".into(), + parent_ref: None, + label: None, + loop_state: None, + model: None, + tokens: None, + tool_calls: None, + last_tool_name: None, + }), + // newer additive no-op signals — keep the no-panic/determinism sweep + // touching them too. + Just(SessionEvent::ToolOutputDelta { + item_id: "call_0".into(), + text: "line\n".into(), + }), + Just(SessionEvent::TurnDiffUpdated { + diff: "diff --git a/x b/x".into(), + }), + Just(SessionEvent::Notice { + level: crate::event::NoticeLevel::Warning, + message: "advisory".into(), + }), + Just(SessionEvent::SessionInfo { + context_usage: None, + cost_text: Some("Total cost: $0".into()), + }), + ] + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(2000))] + + /// P1: step never panics over the abstract state × event cross-product. + #[test] + fn step_never_panics(state in any_state(), event in any_event()) { + let _ = step(&state, event); + } + + /// P2: step is deterministic — folding the same (state, event) twice + /// yields byte-identical (state, transitions). Catches hidden mutable + /// state or non-determinism sneaking into the "pure" reducer. + #[test] + fn step_is_deterministic(state in any_state(), event in any_event()) { + let (s1, t1) = step(&state, event.clone()); + let (s2, t2) = step(&state, event); + prop_assert_eq!(s1, s2); + prop_assert_eq!(t1, t2); + } + } +} diff --git a/crates/aionui-session/src/state.rs b/crates/aionui-session/src/state.rs new file mode 100644 index 000000000..835901ef9 --- /dev/null +++ b/crates/aionui-session/src/state.rs @@ -0,0 +1,640 @@ +//! `SessionState` — the server-authoritative session FSM (§C 6.1, frozen). +//! +//! P0 has EXACTLY 4 enum variants. `RequiresAction` is NOT a variant — it is a +//! sub-condition of `Running` (`requires_action.waiting_on_approval > 0`), so +//! resolving it returns seamlessly to plain `Running` without losing the +//! per-turn carry (FIX 1). The reducer stays a pure fn because all per-turn +//! memory lives inside the `Running` variant (I1). + +/// Server-authoritative session state. P0 = exactly 4 variants. +/// +/// (§A froze a 5-name set incl. a standalone `RequiresAction`; FIX 1 folds it +/// into `Running.requires_action` ⇒ the enum is 4 variants and `RequiresAction` +/// is a derived view via [`is_requires_action`].) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionState { + /// `TurnStarted` seen, adapter is spawning the process + delivering the + /// prompt, no stream-json frame seen yet. + Starting, + /// Turn in progress (triggered by `TurnStarted`, NEVER system/status). + /// Carries the per-turn bookkeeping the pure reducer needs. + Running { + /// This turn's epoch (= `current_epoch` at `TurnStarted`). Diagnostics + /// / `Transition`. Also the reducer's epoch baseline (I3). + since_epoch: u64, + /// OUTPUT-PRESENCE accumulator (C5/I5): set true by a non-empty + /// `MessageDelta` or a `ToolResult`; `ThoughtDelta` never sets it; + /// monotonic (never flips back within the span). + saw_substantive_output: bool, + /// Crash-discrimination flag (C6/I6): has this turn seen a terminal + /// `TurnResult`? drain-before-honor (I11) guarantees it reflects stdout + /// before `step(Detached)`. + terminal_result_seen: bool, + /// RequiresAction sub-condition (FIX 1/FIX 3): ref-counted set. + /// count > 0 ⇒ requires-action sub-state (can_send still false; a user may + /// leave it pending indefinitely — no deadline). count == 0 ⇒ plain Running. + requires_action: RequiresActionSet, + /// ⭐ 007 §6b b1 (the ONE intentional reducer state change): the live + /// subagent roster. `SubagentUpdate` upserts here (key=`ref`, + /// last-write-wins); fed by claude Task/Workflow, codex collab-agent, + /// opencode child-session. Flat Vec + `parent_ref` edges model + /// multi-level (§9.13). I14 prune (terminal entries removed at turn + /// boundary) is enforced at the ORCHESTRATOR layer, NOT here — `step()` + /// only upserts. `can_send_message` does NOT read this (unlock stays + /// FSM-phase + waiting_on_approval/auth only, §9.12 M-12). + subagents: Vec, + }, + /// Terminal error. P0 carries only `{ reason }` (NO `retryable` — later + /// feature). Absorbing state (I10). + Error { reason: ErrorReason }, + /// Idle — may send a new prompt. Successful terminal of a turn. Absorbing + /// state (I10). + Idle, +} + +/// Ref-counted flag set (u32, not bool): resolving ONE does not unlock; only +/// the whole set reaching zero returns to plain `Running` (I7). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RequiresActionSet { + /// Number of pending control-requests (P0 mock-driven, R9). + /// `Permission{kind:Tool}` +1 / its `PermissionResolved` -1. + pub waiting_on_approval: u32, + /// ⭐ 007 §6b b3 (the SECOND intentional reducer change, Addendum 9): pending + /// mid-session re-auth challenges. `Permission{kind:Auth}` +1 / its + /// `PermissionResolved` -1. SEPARATE counter so the UI distinguishes "approve + /// tool" from "please re-login", and `AnswerAuth` vs `AnswerPermission` have + /// distinct homes. `is_requires_action` = either counter > 0. Gated by + /// `Capabilities.auth_methods` non-empty. K2 (re-auth continue-vs-abort, §10) + /// is an adapter-behavior question; the reducer structure (just a counter) + /// accommodates both outcomes unchanged. + pub waiting_on_auth: u32, +} + +/// ⭐ 007 §6b b1/§9.12/§9.13: a subagent's live state in `Running.subagents`. +/// Flat-Vec + `parent_ref` edges model multi-level (top-level = None). `r#ref` +/// is the upsert key (last-write-wins). For a claude Workflow node the adapter +/// mints these by privately tailing on-disk transcripts — the disk paths NEVER +/// appear here (§9.14). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SubagentState { + /// Stable per-subagent ref (codex agentId / opencode child sessionId / + /// claude task id / workflow agent id). The upsert key. + pub r#ref: String, + /// User-visible label (subagent_type / workflow_name), optional. + pub label: Option, + /// Lifecycle status (6-state, codex 7 minus NotFound). + pub status: crate::event::SubagentStatus, + /// Parent subagent ref, or None for a top-level subagent (§9.13). + pub parent_ref: Option, +} + +/// 009 R6 / §3: a background workflow agent in the orchestrator-level +/// `workflow_roster` — the BACKGROUND plane, distinct from the FSM's +/// `Running.subagents` (foreground plane). A workflow lives in `Running.subagents` +/// during the turn that spawned it; once the turn folds Idle the FSM roster is +/// gone, but the workflow_roster entry OUTLIVES it (a Workflow/Task is non-blocking +/// and runs past its turn), so `has_activity` keeps reporting true → semantic-② +/// (the user can talk while a background workflow runs). Cleared only when the +/// task reaches a terminal `task_status` (then retained per §11.3) or on crash / +/// idle-reap. +/// +/// Per-backend fillability (§10 F7): only `ref_id`/`task_status` are mandatory; +/// every rich field is `Option` because claude fills all of them (workflow_progress[]), +/// codex fills only label, and ACP/aionrs have no workflow concept (empty roster). +/// The frontend renders by field PRESENCE — it must not assume any rich field. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WorkflowAgentState { + /// Mandatory upsert key (= agent_id / task_id). + pub ref_id: String, + /// Mandatory task outcome, ORTHOGONAL to the LLM-loop `state` below: a failed + /// agent can be `state:Done` (its loop finished) yet `task_status:Failed`. + pub task_status: WorkflowTaskStatus, + /// Whether this entry has a LIFECYCLE signal — i.e. it was ever touched by a + /// `SubagentUpdate` (the container `task_id`, which receives a `task_notification` + /// terminal). `false` = a `SubagentDetail`-ONLY entry (a per-agent `agentId`/label + /// child whose only frames are enrichment: model/tokens/loop_state). Such a child + /// has NO terminal signal — `task_notification` terminalizes the container, not the + /// child, and many children emit only a single `state:start` and never a `done` + /// (fixture-verified). It must NOT drive `background_active`, else a finished + /// workflow's child pins has_activity=true forever across all later turns. Only + /// lifecycle-bearing entries (containers) count toward background activity. + /// `#[serde(default)]` (→ false): a future wire/persisted entry lacking this field + /// deserializes as detail-only — the conservative "does not pin activity" default. + #[serde(default)] + pub has_lifecycle: bool, + /// Terminal-retention flag (§11.3): a terminal entry is kept for UI history + /// rather than removed immediately. `None` = default (transient). + pub retain: Option, + // ── rich fields, all Option (per-backend fillability §10 F7) ── + /// User-visible label (claude workflow_name / codex spawn model). + pub label: Option, + /// claude-only per-agent LLM-loop phase (start→progress→done; done on + /// success OR failure — orthogonal to `task_status`). + pub state: Option, + /// claude-only: model, last tool, token/tool counters, attempt, previews. + pub model: Option, + pub last_tool_name: Option, + pub tokens: Option, + pub tool_calls: Option, +} + +/// 009 R6: a background workflow agent's task outcome (orthogonal to its LLM +/// loop state). `Running` = still working; the rest are terminal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum WorkflowTaskStatus { + Running, + Completed, + Failed, + Stopped, +} + +impl WorkflowTaskStatus { + /// True for the terminal outcomes (not `Running`) — drives roster cleanup + + /// the `background_active` derivation (a terminal agent no longer counts). + pub fn is_terminal(self) -> bool { + !matches!(self, WorkflowTaskStatus::Running) + } +} + +/// 009 R6: claude-only per-agent LLM-loop phase (workflow_progress[].state). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum WorkflowLoopState { + Start, + Progress, + Done, +} + +/// 009 R6 / §3: does this session have a background workflow still RUNNING? The +/// BACKGROUND half of `has_activity` (the foreground half is +/// `has_foreground_activity`). True iff any LIFECYCLE-bearing roster entry is +/// non-terminal. An empty / all-terminal roster → false (a finished workflow stops +/// the spinner; a session with no workflow never spuriously shows activity). +/// +/// A roster entry counts toward background activity iff BOTH hold: +/// - `has_lifecycle` — it was touched by a `SubagentUpdate` (the container, +/// keyed by `task_id`, which DOES receive a `task_notification` terminal), AND +/// - `task_status` is non-terminal. +/// +/// The `has_lifecycle` conjunct closes the stale-has-activity leak (WS-captured +/// 2026-06-22): claude's per-AGENT `SubagentDetail` entries (keyed by `agentId`/label) +/// default `task_status: Running` and are only ENRICHED (model/tokens/loop_state), +/// NEVER terminalized — `task_notification` terminalizes the CONTAINER (`task_id`), +/// not its per-agent children, and many children emit a single `state:start` and never +/// a `done` (fixture-verified: ~half of workflow_agent refs never reach Done). Because +/// the orchestrator's roster map lives across the whole process (all turns), a finished +/// workflow's detail-only child would otherwise pin the background half `true` FOREVER +/// → every later turn (even plain chat) reports has_activity=true → sidebar spins +/// forever. A detail-only child is display metadata, not a lifecycle; only the +/// lifecycle-bearing container (with its `task_notification` terminal) drives +/// background activity. Real semantic-② (a Workflow CONTAINER outliving its spawning +/// turn) is preserved: that entry is lifecycle-bearing and stays non-terminal until its +/// `task_notification` arrives. The §12.7 process-gone closers +/// (Detached/BackendSuspended clear the roster) handle a container that dies mid-flight. +pub fn background_active(roster: &std::collections::HashMap) -> bool { + roster.values().any(|w| w.has_lifecycle && !w.task_status.is_terminal()) +} + +/// P0 error reasons: exactly 4 variants (§A frozen names). NO 35-variant +/// AgentErrorCode structure (later feature). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ErrorReason { + /// Process exited before this turn saw a terminal result (C6/R7a). + Crashed, + /// `is_error:false` yet no text and no completed tool_use (C5/R5; + /// thinking does not count). + EmptyTurn, + /// Terminal `result{is_error:true}` (C7/R10). Carries backend-neutral + /// diagnostics. + Backend { + /// Normalized HTTP status (claude source: result.api_error_status, + /// err=400 / anthauthfail=401). backend-neutral, not a claude token. + /// `None` = non-HTTP error. + api_error_status: Option, + /// Normalized error text (claude source: result.result, non-empty on + /// error turns). + message: String, + }, +} + +/// The ONLY unlock decision. Pure fn: no I/O, no clock, no interior mutability. +/// Running (incl. the requires_action sub-state) is always false — only Idle +/// admits a new prompt. +/// +/// Truth table (frozen, §C 6.1): +/// | state | can_send | +/// |------------------------------------|----------| +/// | Starting | false | +/// | Running { requires_action empty } | false | +/// | Running { requires_action count>0 }| false | +/// | Error { .. } | false | +/// | Idle | true | +pub fn can_send_message(state: &SessionState) -> bool { + matches!(state, SessionState::Idle) +} + +/// Derived view (009 R2/§1): can the user PROACTIVELY queue a next-turn message +/// while a turn is in flight? Two conjuncts: +/// (a) FSM half — `Running` with NO requires_action (a turn is genuinely in +/// flight, not blocked on a permission/auth the user must answer first). +/// `Starting`/`Error`/`Idle` never queue (Idle is `can_send`, not queue). +/// (b) capability half — the backend `accepts_proactive_input` (claude's stdin +/// FIFO). ⚠️ NOT `caps.supported_commands.steer`: codex advertises steer but +/// the conv layer doesn't route it, so keying off steer would surface a dead +/// queue affordance (MX-QUEUE-3). ACP/aionrs lack the path → degrade to false. +/// +/// Orthogonal to `can_send` (Idle): `can_send || can_queue` is the input-box gate. +/// Truth table (× backend `accepts_proactive_input`): +/// | state | claude(true) | codex/acp/aionrs(false) | +/// |------------------------------------|--------------|-------------------------| +/// | Idle / Starting / Error | false | false | +/// | Running { requires_action empty } | true | false | +/// | Running { requires_action count>0 }| false | false | +pub fn can_queue_message(state: &SessionState, accepts_proactive_input: bool) -> bool { + accepts_proactive_input && matches!(state, SessionState::Running { .. }) && !is_requires_action(state) +} + +/// Derived view (009 R2/§1): can the user cancel right now? `Starting || Running` +/// (a turn is being set up or is running) — INCLUDING the requires_action +/// sub-condition (Esc while waiting on a permission cancels the whole turn, no +/// special case, §1 Esc ruling). `Idle`/`Error` are not cancellable (nothing is +/// in flight; a dead session must NOT report can_cancel=true and fire a phantom +/// interrupt at an already-gone process — CR-15/MX-ERROR-6). +/// Truth table: +/// | Starting | Running { .. } | Error | Idle | → can_cancel | +/// | true | true | false | false| | +pub fn can_cancel(state: &SessionState) -> bool { + matches!(state, SessionState::Starting | SessionState::Running { .. }) +} + +/// Derived view: is the session in the requires-action sub-condition? Used by +/// the UI to render the pending-action badge; introduces NO new state. +pub fn is_requires_action(state: &SessionState) -> bool { + // 007 §6b b3: requires-action is EITHER a pending tool approval OR a pending + // re-auth. Both block can_send; the UI reads the two counters separately. + matches!( + state, + SessionState::Running { requires_action, .. } + if requires_action.waiting_on_approval > 0 || requires_action.waiting_on_auth > 0 + ) +} + +/// Derived view (UI "spinner" signal — the FOREGROUND half of `has_activity`, +/// see session-surface-and-ws-contract §1.6). Pure fn, no new state. Orthogonal +/// to `can_send`: `can_send` answers "can I talk?" (Idle only), this answers "is +/// a task running?" (turn-active spinner). The two combine on the frontend into +/// the two activity semantics: +/// has_activity ∧ !can_send → task running + can't talk (foreground turn busy) +/// has_activity ∧ can_send → task running + can talk (detached background) +/// +/// `requires_action` is EXCLUDED **unless a subagent is still running**: waiting +/// on the user to approve is not the main agent working (show a confirmation card, +/// not a spinner) — BUT a subagent spawned earlier may still be executing while the +/// main turn blocks on approval, and THAT is real work → keep spinning. So the +/// foreground signal is "main turn working OR any subagent active", and the +/// requires_action mute only applies when no subagent is active. +/// +/// Truth table: +/// | state | foreground_activity | +/// |----------------------------------------------------|---------------------| +/// | Starting | true | +/// | Running { requires_action empty } | true | +/// | Running { requires_action count>0, no subagent run}| false | +/// | Running { requires_action count>0, subagent run } | true | +/// | Error { .. } | false | +/// | Idle | false | +/// +/// The backend half (`background_active`, tasks that outlive the turn that +/// started them) is OR'd in by the orchestrator when it builds +/// `StateSnapshot.has_activity`. It is constant-false today — a KNOWN GAP, NOT a +/// correct terminal value. +/// +/// ⚠️ CORRECTION (2026-06-13): an earlier version claimed background_active=false +/// was "empirically correct" / semantic-② "structurally unreachable", citing a C1 +/// capture of a `run_in_background` bash dying with the turn. That conflated bash +/// with a Workflow and was WRONG. Re-measured on claude 2.1.176 (AionCore's exact +/// persistent stream-json flags): a Workflow (Task tool) is NON-BLOCKING and +/// outlives its turn — the process replies to a new message ~2s later while the +/// workflow's 60s sleep still runs, and `result` is deferred to workflow +/// completion. So semantic-② (a task running while you can still talk) IS reachable. +/// See `orchestrator::fold_one` + protocols/samples/claude-cli/2.1.176/ +/// WORKFLOW-VS-BASH-BACKGROUND.md for the full finding and the planned fix. +pub fn has_foreground_activity(state: &SessionState) -> bool { + match state { + SessionState::Starting => true, + SessionState::Running { subagents, .. } => !is_requires_action(state) || any_subagent_active(subagents), + SessionState::Error { .. } | SessionState::Idle => false, + } +} + +/// Is any subagent in the roster still doing work? `PendingInit`/`Running` count +/// as active; the terminal statuses (`Interrupted`/`Completed`/`Errored`/ +/// `Shutdown`) do not. Mirrors the `is_active` convention used elsewhere. +fn any_subagent_active(subagents: &[SubagentState]) -> bool { + use crate::event::SubagentStatus; + subagents + .iter() + .any(|s| matches!(s.status, SubagentStatus::PendingInit | SubagentStatus::Running)) +} + +/// R16/3.9 crash-resume self-heal predicate: does this error reason mean a +/// `--resume` failed because the persisted session id is stale/corrupt? When +/// true, the conversation layer clears the persisted `claude_session_id` + +/// evicts the dead task so the next send rebuilds Fresh instead of wedging on +/// the same bad id. +/// +/// Two signals, because claude 2.1.168 surfaces a failed resume TWO ways (both +/// probe-verified): +/// 1. `"No conversation found …"` text — when the cause lands in the result +/// frame's `result`/`errors[]` (older shape / some paths). +/// 2. `"error_during_execution"` (the `subtype`, folded into the message by +/// `parse_result` when result+errors are empty) — the ACTUAL shape a +/// stale-id resume takes today: a single `result{subtype: +/// "error_during_execution", is_error:true}` whose cause is on STDERR +/// only, then the process exits. Matching the subtype is what makes the +/// self-heal fire when the human-readable cause never reached the frame — +/// without it the conversation wedges permanently (every send re-resumes +/// the dead id). `error_during_execution` is a STRUCTURAL failure: a normal +/// turn — even one with a tool error — terminates `subtype:"success"`, so +/// this never misfires on ordinary errors (probe-verified). +/// +/// Single source of the match (was inlined in the conversation +/// transition-subscriber): keeping it here, beside `ErrorReason`, makes it +/// unit-testable and means a backend wording change is fixed in ONE place. +pub fn is_unrecoverable_resume_error(reason: &ErrorReason) -> bool { + matches!( + reason, + ErrorReason::Backend { message, .. } + if message.contains("No conversation found") || message.contains("error_during_execution") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ra(count: u32) -> SessionState { + SessionState::Running { + since_epoch: 1, + saw_substantive_output: false, + terminal_result_seen: false, + requires_action: RequiresActionSet { + waiting_on_approval: count, + waiting_on_auth: 0, + }, + subagents: Vec::new(), + } + } + + fn ra_with_subagent(count: u32, sub_status: crate::event::SubagentStatus) -> SessionState { + SessionState::Running { + since_epoch: 1, + saw_substantive_output: false, + terminal_result_seen: false, + requires_action: RequiresActionSet { + waiting_on_approval: count, + waiting_on_auth: 0, + }, + subagents: vec![SubagentState { + r#ref: "sub-1".into(), + label: None, + status: sub_status, + parent_ref: None, + }], + } + } + + #[test] + fn can_send_only_idle() { + assert!(can_send_message(&SessionState::Idle)); + assert!(!can_send_message(&SessionState::Starting)); + assert!(!can_send_message(&ra(0))); + assert!(!can_send_message(&ra(2))); + assert!(!can_send_message(&SessionState::Error { + reason: ErrorReason::Crashed + })); + } + + #[test] + fn can_queue_truth_table_capability_gated() { + // 009 R2: can_queue = (Running ∧ no requires_action) ∧ accepts_proactive_input. + // claude (accepts_proactive_input=true): + assert!(can_queue_message(&ra(0), true), "Running no-RA + claude → can queue"); + assert!( + !can_queue_message(&ra(1), true), + "Running+RA must NOT queue (answer first)" + ); + assert!(!can_queue_message(&ra(2), true), "Running+RA (2) must NOT queue"); + assert!( + !can_queue_message(&SessionState::Idle, true), + "Idle is can_send, not queue" + ); + assert!( + !can_queue_message(&SessionState::Starting, true), + "Starting cannot queue" + ); + assert!( + !can_queue_message( + &SessionState::Error { + reason: ErrorReason::Crashed + }, + true + ), + "Error cannot queue" + ); + // codex/acp/aionrs (accepts_proactive_input=false): degrades to false in + // EVERY state — including Running no-RA where claude would queue. This is + // the MX-QUEUE-3 dead-button guard: the gate is the proactive-input bit, + // NOT supported_commands.steer (which codex sets true). + assert!( + !can_queue_message(&ra(0), false), + "no proactive-input path → never queue" + ); + assert!(!can_queue_message(&ra(1), false)); + assert!(!can_queue_message(&SessionState::Idle, false)); + } + + #[test] + fn background_active_is_any_non_terminal_roster_entry() { + use std::collections::HashMap; + // Lifecycle-bearing entries (the container case — touched by SubagentUpdate), + // so task_status drives background_active per the §11.4 terminal absorption. + let mk = |status: WorkflowTaskStatus| WorkflowAgentState { + ref_id: "w".into(), + task_status: status, + has_lifecycle: true, + retain: None, + label: None, + state: None, + model: None, + last_tool_name: None, + tokens: None, + tool_calls: None, + }; + let mut roster: HashMap = HashMap::new(); + assert!(!background_active(&roster), "empty roster → no background activity"); + roster.insert("a".into(), mk(WorkflowTaskStatus::Completed)); + assert!(!background_active(&roster), "all-terminal roster → false"); + roster.insert("b".into(), mk(WorkflowTaskStatus::Running)); + assert!(background_active(&roster), "any Running entry → true"); + roster.insert("b".into(), mk(WorkflowTaskStatus::Failed)); + assert!(!background_active(&roster), "Running→terminal flips it back to false"); + // is_terminal coverage + assert!(!WorkflowTaskStatus::Running.is_terminal()); + for t in [ + WorkflowTaskStatus::Completed, + WorkflowTaskStatus::Failed, + WorkflowTaskStatus::Stopped, + ] { + assert!(t.is_terminal(), "{t:?} is terminal"); + } + } + + /// stale-has-activity fix: the `has_lifecycle` conjunct. A per-agent + /// `SubagentDetail`-only entry (has_lifecycle=false) carries no terminal signal — + /// `task_notification` terminalizes the container, not the child, and many children + /// emit only a single `state:start`, never a `done` (fixture-verified). It must NEVER + /// drive background_active, REGARDLESS of task_status or loop_state; otherwise a + /// finished workflow's child pins has_activity=true forever across later turns. A + /// lifecycle-bearing entry (has_lifecycle=true, a SubagentUpdate container) DOES drive + /// it per task_status (preserving real semantic-② until its task_notification). + #[test] + fn background_active_ignores_detail_only_entries() { + use std::collections::HashMap; + let mk = |has_lifecycle: bool, status: WorkflowTaskStatus, loop_state: Option| { + WorkflowAgentState { + ref_id: "x".into(), + task_status: status, + has_lifecycle, + retain: None, + label: None, + state: loop_state, + model: None, + last_tool_name: None, + tokens: None, + tool_calls: None, + } + }; + let mut roster: HashMap = HashMap::new(); + // Detail-only (has_lifecycle=false): NEVER active, whatever its task_status / + // loop_state — this is the leak the fix closes (used to stay Running forever). + for ls in [Some(WorkflowLoopState::Start), Some(WorkflowLoopState::Progress), None] { + roster.insert("x".into(), mk(false, WorkflowTaskStatus::Running, ls)); + assert!( + !background_active(&roster), + "a detail-only child (loop_state={ls:?}) must NOT drive background_active (closes the leak)" + ); + } + // Lifecycle-bearing container: Running → active, terminal → not. + roster.insert("x".into(), mk(true, WorkflowTaskStatus::Running, None)); + assert!( + background_active(&roster), + "a lifecycle-bearing Running container drives background_active (real semantic-②)" + ); + roster.insert("x".into(), mk(true, WorkflowTaskStatus::Completed, None)); + assert!( + !background_active(&roster), + "a lifecycle-bearing container, once terminal, stops driving background_active" + ); + } + + #[test] + fn can_cancel_truth_table_includes_requires_action() { + // 009 R2: can_cancel = Starting || Running (incl. requires_action; Esc + // while waiting on a permission cancels the whole turn — no special case). + assert!(can_cancel(&SessionState::Starting), "Starting cancellable"); + assert!(can_cancel(&ra(0)), "Running cancellable"); + assert!(can_cancel(&ra(1)), "Running+RA cancellable (no special case)"); + assert!(can_cancel(&ra(2))); + assert!(!can_cancel(&SessionState::Idle), "Idle: nothing to cancel"); + assert!( + !can_cancel(&SessionState::Error { + reason: ErrorReason::Crashed + }), + "Error: dead session must NOT report cancellable (no phantom interrupt)" + ); + } + + #[test] + fn is_requires_action_predicate() { + // directly pins the `> 0` boundary: 0 = false, 1 = true, 2 = true. + assert!(!is_requires_action(&ra(0)), "count 0 is plain Running"); + assert!(is_requires_action(&ra(1)), "count 1 is requires-action"); + assert!(is_requires_action(&ra(2)), "count 2 is requires-action"); + assert!(!is_requires_action(&SessionState::Idle)); + } + + #[test] + fn has_foreground_activity_truth_table() { + // §1.6 spinner signal (foreground half): Starting/Running(working)=true; + // Running(requires_action)=false (waiting on user, show card not spinner); + // Idle/Error=false. Orthogonal to can_send. + assert!(has_foreground_activity(&SessionState::Starting), "Starting → spinner"); + assert!(has_foreground_activity(&ra(0)), "Running working → spinner"); + assert!( + !has_foreground_activity(&ra(1)), + "Running waiting-on-approval, no subagent → NOT spinner (confirmation card)" + ); + // ⭐ requires_action BUT a subagent still running → spinner (the subagent is + // doing real work even though the main turn blocks on approval). + assert!( + has_foreground_activity(&ra_with_subagent(1, crate::event::SubagentStatus::Running)), + "Running waiting-on-approval + subagent running → spinner" + ); + assert!( + !has_foreground_activity(&ra_with_subagent(1, crate::event::SubagentStatus::Completed)), + "Running waiting-on-approval + subagent DONE → NOT spinner (no active work)" + ); + // Subagent active even with no requires_action is still a spinner (the + // ra(0) path already true, but pin it via a subagent on a working turn). + assert!(has_foreground_activity(&ra_with_subagent( + 0, + crate::event::SubagentStatus::PendingInit + ))); + assert!(!has_foreground_activity(&SessionState::Idle), "Idle → no spinner"); + assert!( + !has_foreground_activity(&SessionState::Error { + reason: ErrorReason::Crashed + }), + "Error → no spinner" + ); + // Orthogonality with can_send: Starting is busy (spinner) but NOT sendable. + assert!(has_foreground_activity(&SessionState::Starting) && !can_send_message(&SessionState::Starting)); + // Idle is sendable but NOT busy. + assert!(can_send_message(&SessionState::Idle) && !has_foreground_activity(&SessionState::Idle)); + } + + #[test] + fn unrecoverable_resume_error_predicate() { + let backend = |status: Option, msg: &str| ErrorReason::Backend { + api_error_status: status, + message: msg.into(), + }; + // The real bad-resume terminal (probe-verified wording) → true. + assert!(is_unrecoverable_resume_error(&backend( + None, + "No conversation found with session ID: stale-xyz" + ))); + // The ACTUAL shape a stale-id resume takes on claude 2.1.168: the cause + // ("No conversation found") is on STDERR, so the frame carries only + // `subtype:"error_during_execution"`, which parse_result folds into the + // message. Self-heal MUST fire on this (else the conversation wedges). + assert!( + is_unrecoverable_resume_error(&backend(None, "error_during_execution")), + "the error_during_execution subtype (stderr-only cause) MUST self-heal" + ); + // A genuine backend error that is NOT a bad resume → false (must NOT + // clear the session id / evict on a normal 429 or auth failure). + assert!(!is_unrecoverable_resume_error(&backend(Some(429), "rate limited"))); + assert!(!is_unrecoverable_resume_error(&backend(Some(401), "invalid x-api-key"))); + assert!(!is_unrecoverable_resume_error(&backend(None, ""))); + // A normal turn — even one whose tool failed — terminates subtype:"success" + // (probe-verified), so an ordinary error message never misfires. + assert!(!is_unrecoverable_resume_error(&backend( + None, + "the command failed with exit code 3" + ))); + // Non-Backend error reasons never self-heal the session id. + assert!(!is_unrecoverable_resume_error(&ErrorReason::Crashed)); + assert!(!is_unrecoverable_resume_error(&ErrorReason::EmptyTurn)); + } +} diff --git a/crates/aionui-session/src/testing.rs b/crates/aionui-session/src/testing.rs new file mode 100644 index 000000000..cd5f7efc3 --- /dev/null +++ b/crates/aionui-session/src/testing.rs @@ -0,0 +1,678 @@ +//! In-crate test doubles (D2/D8), gated behind `test-support` / `cfg(test)`. +//! NEVER fabricates a `ManagedProcess` (its fields are private, only ctor is the +//! real `spawn`). Instead: +//! - `FakeAgentIo` implements the narrow `AgentIo` seam over `tokio::io::duplex`, +//! replaying scripted fixture bytes + a scripted exit. +//! - `FakeSpawner` implements the real 001 `Spawner` trait, RECORDS that it was +//! called, and returns `Err` (it cannot synthesize an `Arc` +//! without a real OS process — see the seam note). T14 uses the call-count to +//! kill the "start_turn bypasses the injected spawner" mutation hermetically. +//! - `ScriptedConnection` implements `BackendConnection` by building a +//! `ClaudeSessionBackend` over a `FakeAgentIo` (NDJSON bytes pre-loaded) for +//! every `open_session` call. Records the last `SessionSpec` for resume assertions. +//! - `StdoutGate` is a thin handle around the `FakeAgentIo`'s internal stdout-gate +//! pair (`Arc` + `Arc`), cloned out at construction time so an +//! external caller (e.g. `ScriptedConnection`) can release the gated bytes AFTER +//! the orchestrator has subscribed — avoiding the late-subscriber event-loss race. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use aionui_common::CommandSpec; +use aionui_process::{BoxedStdin, BoxedStdout, ManagedProcess, ProcessError, Spawner}; +use tokio::sync::Mutex; + +use crate::adapter::{AgentIo, BackendAdapter}; +use crate::backend::{ + BackendConnection, BackendError, ClaudeSessionBackend, SessionBackend, SessionConfig, SessionSpec, +}; +use crate::capability::Capabilities; +use crate::event::ExitStatusLite; + +/// A duplex-backed `AgentIo`: hands the read half (preloaded with scripted +/// bytes) to the transport as stdout, and resolves `wait_for_exit` to a +/// scripted status once the bytes are consumed. +pub struct FakeAgentIo { + /// stdout bytes the "process" will emit this turn. + scripted_stdout: std::sync::Mutex>>, + /// Optional SECOND chunk emitted only after the stdout gate opens (models a + /// handshake-then-turn split: the prefix — e.g. `thread/started` — flows + /// immediately so `dispatch` can bind the threadId, while the turn-driving + /// notifications wait until a subscriber is wired). `None` = no gated tail. + gated_tail: std::sync::Mutex>>, + /// Optional ORDERED gated SEGMENTS, each released one-at-a-time (`release_next`). + /// Independent of `gated_tail` (the single-shot tail): segments model a turn + /// that must pause MID-STREAM for a host action — e.g. a permission round-trip + /// (segment 0 = the `control_request`; segment 1 = the continuation+result that + /// flows only after the user answers) or a multi-turn fixture (one segment per + /// turn). Empty by default → existing single-tail behavior is unchanged. The + /// writer task acquires one `segment_gate` permit before emitting each segment. + gated_segments: std::sync::Mutex>>>, + /// One permit per released segment. A `Semaphore` (not `Notify`) so a release + /// that races ahead of the writer parking is NOT lost — permits accumulate, so + /// staged release is timing-independent (no lost-wakeup flakiness). + segment_gate: Arc, + /// scripted exit; `None` models "exited, status unknown" (D3). + exit: Option, + /// flips false→true once `take_stdio` has been called (once-only contract). + taken: AtomicUsize, + /// gate that `wait_for_exit` awaits, so a test can control exit timing. + exit_gate: Arc, + exit_ready: Arc, + /// gate the SCRIPTED STDOUT emission, so a test can defer the bytes until its + /// subscribers are wired (broadcast does not replay to late subscribers — the + /// end-to-end orchestrator fold needs run() subscribed before the turn drives). + /// Default = open (emit immediately, preserving every existing test); a test + /// opts into gating via `gated_stdout()` and opens it with `release_stdout()`. + stdout_gate: Arc, + stdout_ready: Arc, + /// All bytes written to the stdin half, captured for assertions (the + /// single-writer / NDJSON-framing checks, I-13). The drain task appends + /// here instead of discarding. + captured_stdin: Arc>>, + /// Scripted stderr tail returned by `peek_stderr` (S19 crash-diagnostic + /// tests). Empty by default. + scripted_stderr: String, + /// When true, `take_stdio` returns `None` — models a DEGENERATE spawn whose + /// stdio could not be taken (so the backend's stdin slot stays `None` and the + /// first `deliver_prompt` hits the "stdin unavailable" arm). Used to exercise + /// the first-send readiness classification without a real broken pipe. + no_stdio: bool, +} + +impl FakeAgentIo { + /// A turn that emits `bytes` then exits with `exit`. + pub fn new(bytes: Vec, exit: Option) -> Self { + Self { + scripted_stdout: std::sync::Mutex::new(Some(bytes)), + gated_tail: std::sync::Mutex::new(None), + gated_segments: std::sync::Mutex::new(None), + segment_gate: Arc::new(tokio::sync::Semaphore::new(0)), + exit, + taken: AtomicUsize::new(0), + exit_gate: Arc::new(tokio::sync::Notify::new()), + exit_ready: Arc::new(std::sync::atomic::AtomicBool::new(false)), + captured_stdin: Arc::new(tokio::sync::Mutex::new(Vec::new())), + scripted_stderr: String::new(), + // Default: stdout flows immediately (every existing test relies on this). + stdout_gate: Arc::new(tokio::sync::Notify::new()), + stdout_ready: Arc::new(std::sync::atomic::AtomicBool::new(true)), + no_stdio: false, + } + } + + /// Model a DEGENERATE spawn: `take_stdio` returns `None`, so the backend's stdin + /// slot stays `None` and the first `deliver_prompt` hits the "stdin unavailable" + /// arm — used to exercise the first-send readiness classification. + pub fn no_stdio() -> Self { + let mut me = Self::new(Vec::new(), None); + me.no_stdio = true; + me + } + + /// Set the scripted stderr tail that `peek_stderr` will return (models a + /// startup-crash diagnostic, e.g. `error: unknown option '--foo'`). + pub fn with_stderr(mut self, stderr: impl Into) -> Self { + self.scripted_stderr = stderr.into(); + self + } + + /// A handle to the captured-stdin buffer (all bytes the manager wrote over + /// stdin via `deliver_prompt`). Clone it BEFORE `take_stdio` to assert on + /// prompt framing after sends settle. + pub fn captured_stdin(&self) -> Arc>> { + Arc::clone(&self.captured_stdin) + } + + /// A turn whose process NEVER exits (for the never-returning negative + /// control, T2) and emits the given bytes. + pub fn never_exits(bytes: Vec) -> Self { + let me = Self::new(bytes, None); + // exit_ready stays false forever; wait_for_exit blocks on the gate. + me + } + + /// Signal that the scripted exit may now be observed by `wait_for_exit`. + pub fn release_exit(&self) { + self.exit_ready.store(true, Ordering::SeqCst); + self.exit_gate.notify_waiters(); + } + + /// Split the scripted stdout into an immediate PREFIX and a gated TAIL: the + /// prefix flows at `take_stdio` (so `dispatch` can bind the threadId from a + /// `thread/started`), and the tail (the turn-driving notifications) waits until + /// `release_stdout()` — used when a subscriber must be wired before the turn + /// drives (broadcast does not replay to late subscribers). `new(prefix, exit)` + /// supplies the prefix; this sets the gated tail. + pub fn with_gated_tail(self, tail: Vec) -> Self { + *self.gated_tail.lock().unwrap() = Some(tail); + self.stdout_ready.store(false, Ordering::SeqCst); + self + } + + /// Open the stdout gate so the deferred TAIL bytes flow. + pub fn release_stdout(&self) { + self.stdout_ready.store(true, Ordering::SeqCst); + self.stdout_gate.notify_waiters(); + } + + /// A release handle usable AFTER the fake is moved into a backend (which + /// consumes the `Box`). Calling it opens the gated-tail gate. + pub fn stdout_releaser(&self) -> impl Fn() + Send + Sync + 'static { + let gate = Arc::clone(&self.stdout_gate); + let ready = Arc::clone(&self.stdout_ready); + move || { + ready.store(true, Ordering::SeqCst); + gate.notify_waiters(); + } + } + + /// Split the scripted stdout into ORDERED gated SEGMENTS, each released + /// one-at-a-time via [`release_next`](Self::release_next). Unlike the single + /// `gated_tail`, segments model a stream that must pause MID-flight for a host + /// action between chunks (a permission round-trip, or one segment per turn in a + /// multi-turn fixture). The PREFIX (`new`'s bytes) still flows at `take_stdio`; + /// then segment N flows only once `release_next` has been called N+1 times. + /// Mutually exclusive with `with_gated_tail` (a fixture uses one mechanism or + /// the other); the segments path leaves `gated_tail` None. + pub fn with_gated_segments(self, segments: Vec>) -> Self { + *self.gated_segments.lock().unwrap() = Some(segments); + self.stdout_ready.store(false, Ordering::SeqCst); + self + } + + /// Release the NEXT gated segment (adds one permit). Call once per segment, in + /// order. Extra calls past the last segment are harmless (surplus permits are + /// never acquired — the writer has nothing more to emit). + pub fn release_next(&self) { + self.segment_gate.add_permits(1); + } + + /// A per-segment release handle usable AFTER the fake is moved into a backend. + /// Each call adds one permit (releases one segment). + pub fn segment_releaser(&self) -> impl Fn() + Send + Sync + 'static { + let gate = Arc::clone(&self.segment_gate); + move || gate.add_permits(1) + } +} + +#[async_trait::async_trait] +impl AgentIo for FakeAgentIo { + async fn take_stdio(&self) -> Option<(BoxedStdin, BoxedStdout)> { + // Degenerate spawn: stdio could not be taken at all. + if self.no_stdio { + return None; + } + // once-only: second call returns None. + if self.taken.fetch_add(1, Ordering::SeqCst) != 0 { + return None; + } + let bytes = self.scripted_stdout.lock().unwrap().take().unwrap_or_default(); + let tail = self.gated_tail.lock().unwrap().take(); + let segments = self.gated_segments.lock().unwrap().take(); + // a duplex pair: we feed `bytes` into one end, hand the other as stdout. + let seg_max = segments + .as_ref() + .map_or(0, |segs| segs.iter().map(Vec::len).max().unwrap_or(0)); + let cap = bytes.len().max(tail.as_ref().map_or(0, Vec::len)).max(seg_max).max(1); + let (mut feed, read) = tokio::io::duplex(cap); + let stdout_gate = Arc::clone(&self.stdout_gate); + let stdout_ready = Arc::clone(&self.stdout_ready); + let segment_gate = Arc::clone(&self.segment_gate); + tokio::spawn(async move { + use tokio::io::AsyncWriteExt; + // PREFIX flows immediately (handshake — e.g. thread/started). + let _ = feed.write_all(&bytes).await; + let _ = feed.flush().await; + // Optional gated TAIL (the turn): held until release_stdout opens the + // gate (default with no tail: gate is already open, nothing to wait for). + if let Some(tail) = tail { + while !stdout_ready.load(Ordering::SeqCst) { + stdout_gate.notified().await; + } + let _ = feed.write_all(&tail).await; + } + // Optional ORDERED gated SEGMENTS: acquire one permit before each + // segment, so segment N flows only after the N+1-th `release_next`. + // A Semaphore (not Notify) makes this timing-independent — a release + // that races ahead of the writer parking is preserved as a permit. + if let Some(segments) = segments { + for seg in segments { + // `forget` the permit so it is consumed (one permit ⇒ one segment). + if let Ok(permit) = segment_gate.acquire().await { + permit.forget(); + } + let _ = feed.write_all(&seg).await; + let _ = feed.flush().await; + } + } + let _ = feed.shutdown().await; + }); + // stdin: a sink whose peer is continuously drained INTO the capture + // buffer, so prompt writes (F1 `deliver_prompt`, including frames larger + // than the duplex buffer) succeed instead of breaking the pipe, and the + // I-13 single-writer / NDJSON-framing checks can assert on the bytes. + let (sink, mut discard) = tokio::io::duplex(256); + let captured = Arc::clone(&self.captured_stdin); + tokio::spawn(async move { + use tokio::io::AsyncReadExt; + let mut scratch = [0u8; 256]; + loop { + match discard.read(&mut scratch).await { + Ok(0) | Err(_) => break, + Ok(n) => captured.lock().await.extend_from_slice(&scratch[..n]), + } + } + }); + Some((Box::new(sink) as BoxedStdin, Box::new(read) as BoxedStdout)) + } + + async fn wait_for_exit(&self) -> Option { + // block until released (never, for never_exits); then yield scripted. + while !self.exit_ready.load(Ordering::SeqCst) { + self.exit_gate.notified().await; + } + self.exit + } + + async fn peek_stderr(&self, _max_lines: usize) -> String { + self.scripted_stderr.clone() + } +} + +/// A `Spawner` that records its call count and returns `Err`. Used by T14 to +/// prove `start_turn` routes through the INJECTED spawner: a bypassing impl +/// leaves the count at 0. Cannot return a real `ManagedProcess` (no public +/// ctor / no real process), which is fine — the seam test only needs the call. +#[derive(Default)] +pub struct FakeSpawner { + calls: AtomicUsize, + last_command: Mutex>, +} + +impl FakeSpawner { + pub fn new() -> Self { + Self::default() + } + + pub fn call_count(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + pub async fn last_command(&self) -> Option { + self.last_command.lock().await.clone() + } +} + +#[async_trait::async_trait] +impl Spawner for FakeSpawner { + async fn spawn( + &self, + spec: CommandSpec, + _extra_env: &[(String, String)], + _opaque_owner_tag: &str, + ) -> Result, ProcessError> { + self.calls.fetch_add(1, Ordering::SeqCst); + *self.last_command.lock().await = Some(spec); + // Cannot synthesize an Arc without a real process. + Err(ProcessError::internal( + "FakeSpawner: records the call, does not spawn a real process", + )) + } +} + +// ─── ScriptedConnection ────────────────────────────────────────────────────── + +/// A `BackendConnection` that builds a `ClaudeSessionBackend` over scripted NDJSON +/// bytes (a `FakeAgentIo`) for every `open_session` call. Designed for integration +/// tests that want a complete fake→registry→orchestrator→facade→WS pipeline without +/// spawning a real agent process. +/// +/// - `script` — the NDJSON bytes the fake "process" will emit (e.g. a real claude +/// CLI stream-json fixture). The backend's reader task drains them as if they came +/// from a live process. +/// - `last_spec` — records the `SessionSpec` passed to the most recent +/// `open_session` call; lets a test assert resume semantics. +/// +/// # Timing note +/// +/// The script bytes are placed in the FakeAgentIo's GATED TAIL (not the immediate +/// prefix). This prevents the backend reader task from emitting events before the +/// orchestrator has subscribed to `backend.events()` — a late-subscriber event-loss +/// race that causes `streamDelta`/`blockFinal` to silently disappear. The test must +/// call [`ScriptedConnection::release_pending`] after `session.send(...)` (i.e. +/// after the orchestrator has a live receiver) to unblock the bytes. +/// +/// # Usage +/// ```ignore +/// let conn = Arc::new(ScriptedConnection::from_bytes(FIXTURE)); +/// // pass conn to TestBackends / ClaudeSessionRegistry... +/// // ... drive session.send(...)... +/// conn.release_pending(); // ← release gated bytes so reader can emit events +/// ``` +pub struct ScriptedConnection { + script: Vec, + /// When set, `open_session` builds a SEGMENTED io (one gated segment per entry) + /// instead of a single gated tail. Releases are staged via `release_next_segment` + /// — used by the permission round-trip (control_request, then continuation) and + /// the multi-turn fixture (one segment per turn). `None` = single-tail mode. + segments: Option>>, + /// The `SessionSpec` from the most recent `open_session` call. `None` before + /// any session is opened. + pub last_spec: Mutex>, + /// Releaser closures for every `FakeAgentIo` created by `open_session`. Each + /// closure opens that io's gated-tail stdout gate. Accumulated so that + /// `release_pending` drains all of them at once. + pending_releasers: Mutex>>, + /// Per-segment releaser closures for every SEGMENTED `FakeAgentIo`. Each + /// `release_next_segment` call invokes each one once (adds one permit), so every + /// open session advances to its next gated segment. + segment_releasers: Mutex>>, +} + +impl ScriptedConnection { + /// Build a new `ScriptedConnection` with the given scripted NDJSON bytes. + pub fn new(script: Vec) -> Self { + Self { + script, + segments: None, + last_spec: Mutex::new(None), + pending_releasers: Mutex::new(Vec::new()), + segment_releasers: Mutex::new(Vec::new()), + } + } + + /// Convenience: build from an `&[u8]` slice (e.g. `include_bytes!(...)`). + pub fn from_bytes(bytes: &[u8]) -> Self { + Self::new(bytes.to_vec()) + } + + /// Build a SEGMENTED connection: `open_session` gives the backend a FakeAgentIo + /// whose stdout is split into ordered gated segments, each released one-at-a-time + /// via [`release_next_segment`](Self::release_next_segment). Use for fixtures that + /// must pause MID-stream for a host action — a permission round-trip (segment 0 = + /// the `control_request`; segment 1 = the post-answer continuation + result) or a + /// multi-turn fixture (one segment per turn, released after each `session.send`). + pub fn from_segments(segments: Vec>) -> Self { + Self { + script: Vec::new(), + segments: Some(segments), + last_spec: Mutex::new(None), + pending_releasers: Mutex::new(Vec::new()), + segment_releasers: Mutex::new(Vec::new()), + } + } + + /// Release the gated stdout bytes for every session opened so far. Call this + /// AFTER `session.send(...)` returns (i.e. after the orchestrator has subscribed + /// to `backend.events()`) so the reader task's events land on active receivers. + /// Safe to call more than once (extra calls are no-ops: the `AtomicBool` is + /// already true and `notify_waiters` with no waiters is a no-op). + pub async fn release_pending(&self) { + let releasers = std::mem::take(&mut *self.pending_releasers.lock().await); + for r in releasers { + r(); + } + } + + /// Release the NEXT gated segment for every segmented session opened so far + /// (adds one permit to each). Call once per stage, in order, AFTER the dispatch + /// that should precede that segment (e.g. after `session.send` for segment 0, + /// after `session.answerPermission` for segment 1). Idempotent past the last + /// segment (surplus permits are never consumed). + pub async fn release_next_segment(&self) { + for r in self.segment_releasers.lock().await.iter() { + r(); + } + } +} + +#[async_trait::async_trait] +impl BackendConnection for ScriptedConnection { + async fn open_session( + &self, + spec: SessionSpec, + _config: SessionConfig, + ) -> Result, BackendError> { + *self.last_spec.lock().await = Some(spec.clone()); + let session_id = match &spec { + SessionSpec::Fresh { session_id } => session_id.clone(), + SessionSpec::Resume { session_id, .. } => session_id.clone(), + }; + // Gate the NDJSON bytes so the reader does NOT emit events before the + // orchestrator subscribes to `backend.events()` (a late-subscriber drop). + // Single-tail mode (the default) releases everything at once via + // `release_pending`; segmented mode releases one segment per stage via + // `release_next_segment`. + let io = match &self.segments { + Some(segments) => { + let io = FakeAgentIo::never_exits(Vec::new()).with_gated_segments(segments.clone()); + self.segment_releasers + .lock() + .await + .push(Box::new(io.segment_releaser())); + io + } + None => { + let io = FakeAgentIo::never_exits(Vec::new()).with_gated_tail(self.script.clone()); + self.pending_releasers.lock().await.push(Box::new(io.stdout_releaser())); + io + } + }; + let backend = ClaudeSessionBackend::build_with_io(session_id, Box::new(io)).await; + Ok(Arc::new(backend)) + } + + async fn close_session(&self, _session_id: &str) -> Result<(), BackendError> { + Ok(()) + } + + fn capabilities(&self) -> Capabilities { + // Return the standard claude capabilities snapshot. + crate::adapter::ClaudeAdapter::new().capabilities() + } +} + +/// Cross-backend capability/dispatch invariant ASSERTIONS — the single logic +/// source shared by the in-session invariant tests (claude/codex/acp) AND the +/// `aionui-aionrs` crate's own invariant test (aionrs depends on session, so it +/// cannot live in session's `tests/` and be reached from aionrs — it lives here, +/// callable by both). Plan B (no logic duplication across crates): change an +/// assertion once here and all 4 backends follow. +/// +/// These are the verbatim bodies hoisted out of `tests/cap_behavior_invariant.rs` +/// and `tests/cap_emits_invariant.rs` — see those files' module docs for the +/// defect class each pins (GAP-A advertised-but-stub / Gap-1 silent-block-drop / +/// mis-drain pending queue / auth-triangle). +pub mod invariants { + use crate::backend::{ + BackendError, CancelTarget, Command, CommandMeta, ContentBlock, PermissionDecision, SessionBackend, + }; + use crate::capability::Capabilities; + use crate::event::SessionEvent; + use futures_util::StreamExt; + + type GatedCommand = (&'static str, Command, fn(&Capabilities) -> bool); + + fn gated_commands() -> Vec { + vec![ + ("steer", Command::Steer { content: Vec::new() }, |c| { + c.supported_commands.steer + }), + ( + "cancel_tool", + Command::Cancel { + target: CancelTarget::Tool("t".into()), + }, + |c| c.supported_commands.cancel_tool, + ), + ( + "answer_permission", + Command::AnswerPermission { + request_id: "r".into(), + decision: PermissionDecision::Denied, + selected: None, + answers: Vec::new(), + }, + |c| c.supported_commands.answer_permission, + ), + ( + "answer_auth", + Command::AnswerAuth { + method_id: "m".into(), + credentials: serde_json::Value::Null, + }, + |c| c.supported_commands.answer_auth, + ), + ("acknowledge", Command::Acknowledge { node_id: "n".into() }, |c| { + c.supported_commands.acknowledge + }), + ("set_mode", Command::SetMode { mode: "plan".into() }, |c| { + c.supported_commands.set_mode + }), + ("set_model", Command::SetModel { model: "m".into() }, |c| { + c.supported_commands.set_model + }), + ("rewind", Command::Rewind { num_turns: 1 }, |c| { + c.supported_commands.rewind + }), + ("list_checkpoints", Command::ListCheckpoints, |c| { + c.supported_commands.list_checkpoints + }), + ] + } + + /// false cap ⟺ dispatch rejects CommandNotSupported; true cap ⟹ NOT rejected + /// as unsupported (may Err for a precondition — that is fulfilled-but-gated). + pub async fn assert_cap_dispatch_consistent(backend: &dyn SessionBackend, label: &str) { + let caps = backend.capabilities(); + for (name, cmd, advertised) in gated_commands() { + let is_advertised = advertised(&caps); + let res = backend.dispatch(cmd).await; + let rejected = matches!(res, Err(BackendError::CommandNotSupported { command }) if command == name); + if is_advertised { + assert!( + !rejected, + "[{label}] supported_commands.{name}=true but dispatch returned \ + CommandNotSupported{{{name}}} — declared-but-not-fulfilled (GAP-A class)" + ); + } else { + assert!( + rejected, + "[{label}] supported_commands.{name}=false but dispatch did NOT reject with \ + CommandNotSupported{{{name}}} (got {res:?}) — accepts an un-advertised command" + ); + } + } + } + + type GatedBlock = (&'static str, ContentBlock, fn(&Capabilities) -> bool); + + fn gated_blocks() -> Vec { + vec![ + ("content_block:text", ContentBlock::Text("hi".into()), |c| { + c.prompt_blocks.text + }), + ( + "content_block:image", + ContentBlock::Image { + data: vec![0u8, 1, 2], + media_type: "image/png".into(), + }, + |c| c.prompt_blocks.image, + ), + ( + "content_block:audio", + ContentBlock::Audio { + data: vec![0u8, 1, 2], + media_type: "audio/wav".into(), + }, + |c| c.prompt_blocks.audio, + ), + ( + "content_block:resource", + ContentBlock::ResourceLink { + uri: "file:///x".into(), + mime_type: None, + }, + |c| c.prompt_blocks.resource, + ), + ( + "content_block:at_mention", + ContentBlock::AtMention { user_id: "u1".into() }, + |c| c.prompt_blocks.at_mention, + ), + ] + } + + /// un-advertised block ⟹ Send rejects CommandNotSupported{content_block:} + /// (never silently dropped); advertised block ⟹ NOT rejected as unsupported. + pub async fn assert_block_dispatch_consistent(backend: &dyn SessionBackend, label: &str) { + let caps = backend.capabilities(); + for (name, block, advertised) in gated_blocks() { + let is_advertised = advertised(&caps); + let res = backend + .dispatch(Command::Send { + content: vec![block], + metadata: Default::default(), + }) + .await; + let rejected = matches!(res, Err(BackendError::CommandNotSupported { command }) if command == name); + if is_advertised { + assert!( + !rejected, + "[{label}] prompt_blocks {name}=true but Send rejected it CommandNotSupported \ + — gated a block it claims to accept" + ); + } else { + assert!( + rejected, + "[{label}] prompt_blocks {name}=false but Send did NOT reject (got {res:?}) \ + — silently accepted/dropped an un-advertised block (Gap-1)" + ); + } + } + } + + /// Send WITHOUT a client_msg_id must emit NO PromptAccepted (a spurious one + /// would mis-drain the conversation pending queue). Uniform across backends. + pub async fn assert_send_without_id_emits_no_prompt_accepted(backend: &B, label: &str) { + let mut events = backend.events(); + let _ = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("hi".into())], + metadata: CommandMeta { + client_msg_id: None, + ..Default::default() + }, + }) + .await; + let saw_pa = tokio::time::timeout(std::time::Duration::from_millis(400), async { + while let Some(env) = events.next().await { + if matches!(env.event, SessionEvent::PromptAccepted { .. }) { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!( + !saw_pa, + "{label}: Send with no client_msg_id must emit NO PromptAccepted (would mis-drain pending queue)" + ); + } + + /// auth_methods non-empty ⟺ answer_auth (the third leg of the auth triangle; + /// cap_behavior ties answer_auth⟺AnswerAuth-accepted). + pub fn assert_auth_methods_match_answer_auth(backend: &B, label: &str) { + let caps = backend.capabilities(); + assert_eq!( + !caps.auth_methods.is_empty(), + caps.supported_commands.answer_auth, + "{label}: auth_methods non-empty ({:?}) must agree with answer_auth ({}) — inconsistent", + caps.auth_methods, + caps.supported_commands.answer_auth + ); + } +} diff --git a/crates/aionui-session/tests/fixtures/claude_2.1.169_single_tool_turn.ndjson b/crates/aionui-session/tests/fixtures/claude_2.1.169_single_tool_turn.ndjson new file mode 100644 index 000000000..0d45ba295 --- /dev/null +++ b/crates/aionui-session/tests/fixtures/claude_2.1.169_single_tool_turn.ndjson @@ -0,0 +1,15 @@ +{"type":"system","subtype":"hook_started","hook_id":"97b0596e-e8ec-4366-87cb-c4bee15d2203","hook_name":"SessionStart:startup","hook_event":"SessionStart","uuid":"fc19b28f-baa5-43d2-8f0f-006870c01cac","session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae"} +{"type":"system","subtype":"hook_response","hook_id":"97b0596e-e8ec-4366-87cb-c4bee15d2203","hook_name":"SessionStart:startup","hook_event":"SessionStart","output":"","stdout":"","stderr":"","exit_code":0,"outcome":"success","uuid":"73510159-909f-486d-b715-df46d553966f","session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae"} +{"type":"system","subtype":"init","cwd":"/private/tmp/claude-subagent-probe","session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","ListMcpResourcesTool","LSP","NotebookEdit","Read","ReadMcpResourceTool","ScheduleWakeup","SendMessage","Skill","TaskCreate","TaskGet","TaskList","TaskOutput","TaskStop","TaskUpdate","TeamCreate","TeamDelete","ToolSearch","WebFetch","Workflow","Write","mcp__argocd-dev__abort_rollout","mcp__argocd-dev__create_job","mcp__argocd-dev__full_promote_rollout","mcp__argocd-dev__get_application","mcp__argocd-dev__get_application_logs","mcp__argocd-dev__get_application_resource_tree","mcp__argocd-dev__get_project","mcp__argocd-dev__list_applications","mcp__argocd-dev__list_clusters","mcp__argocd-dev__list_projects","mcp__argocd-dev__list_repositories","mcp__argocd-dev__promote_rollout","mcp__argocd-dev__restart_rollout","mcp__argocd-dev__retry_rollout","mcp__argocd-dev__rollback_application","mcp__argocd-dev__update_application_version","mcp__atlassian__addCommentToJiraIssue","mcp__atlassian__addWorklogToJiraIssue","mcp__atlassian__atlassianUserInfo","mcp__atlassian__createConfluenceFooterComment","mcp__atlassian__createConfluenceInlineComment","mcp__atlassian__createConfluencePage","mcp__atlassian__createIssueLink","mcp__atlassian__createJiraIssue","mcp__atlassian__editJiraIssue","mcp__atlassian__fetch","mcp__atlassian__getAccessibleAtlassianResources","mcp__atlassian__getConfluenceCommentChildren","mcp__atlassian__getConfluencePage","mcp__atlassian__getConfluencePageDescendants","mcp__atlassian__getConfluencePageFooterComments","mcp__atlassian__getConfluencePageInlineComments","mcp__atlassian__getConfluenceSpaces","mcp__atlassian__getIssueLinkTypes","mcp__atlassian__getJiraIssue","mcp__atlassian__getJiraIssueRemoteIssueLinks","mcp__atlassian__getJiraIssueTypeMetaWithFields","mcp__atlassian__getJiraProjectIssueTypesMetadata","mcp__atlassian__getPagesInConfluenceSpace","mcp__atlassian__getTransitionsForJiraIssue","mcp__atlassian__getVisibleJiraProjects","mcp__atlassian__lookupJiraAccountId","mcp__atlassian__search","mcp__atlassian__searchConfluenceUsingCql","mcp__atlassian__searchJiraIssuesUsingJql","mcp__atlassian__transitionJiraIssue","mcp__atlassian__updateConfluencePage","mcp__plugin_aliyun-observability_aliyun-observability__cms_execute_promql_query","mcp__plugin_aliyun-observability_aliyun-observability__sls_describe_logstore","mcp__plugin_aliyun-observability_aliyun-observability__sls_diagnose_query","mcp__plugin_aliyun-observability_aliyun-observability__sls_execute_sql_query","mcp__plugin_aliyun-observability_aliyun-observability__sls_get_current_time","mcp__plugin_aliyun-observability_aliyun-observability__sls_get_regions","mcp__plugin_aliyun-observability_aliyun-observability__sls_list_logstores","mcp__plugin_aliyun-observability_aliyun-observability__sls_list_projects","mcp__plugin_superset_rc-superset__authenticate","mcp__plugin_superset_rc-superset__complete_authentication","mcp__plugin_superset_superset-v2__authenticate","mcp__plugin_superset_superset-v2__complete_authentication","mcp__stardust__authenticate","mcp__stardust__complete_authentication"],"mcp_servers":[{"name":"plugin:aliyun-observability:aliyun-observability","status":"connected"},{"name":"plugin:pionex-crm:pionex-crm","status":"failed"},{"name":"plugin:superset:rc-superset","status":"needs-auth"},{"name":"plugin:superset:superset-v2","status":"needs-auth"},{"name":"aliyun-observability","status":"failed"},{"name":"atlassian","status":"connected"},{"name":"stardust","status":"needs-auth"},{"name":"argocd-dev","status":"connected"}],"model":"global.anthropic.claude-opus-4-8","permissionMode":"bypassPermissions","slash_commands":["deep-research","polymarket-api:polymarket-api","superset:superset-guide","microservice-manifest-spec:manifest-init","microservice-manifest-spec:manifest-spec","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","loop","claude-api","run","run-skill-generator","clear","compact","context","heapdump","init","reload-skills","review","security-review","usage","insights","goal","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.169","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["deep-research","polymarket-api:polymarket-api","superset:superset-guide","microservice-manifest-spec:manifest-init","microservice-manifest-spec:manifest-spec","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","loop","claude-api","run","run-skill-generator"],"plugins":[{"name":"polymarket-api","path":"/Users/zhangruofan/.claude/plugins/cache/future-skills/polymarket-api/1.0.0","source":"polymarket-api@future-skills"},{"name":"aliyun-observability","path":"/Users/zhangruofan/.claude/plugins/cache/growthx-marketplace/aliyun-observability/0.1.0","source":"aliyun-observability@growthx-marketplace"},{"name":"pionex-crm","path":"/Users/zhangruofan/.claude/plugins/cache/growthx-marketplace/pionex-crm/0.1.0","source":"pionex-crm@growthx-marketplace"},{"name":"superset","path":"/Users/zhangruofan/.claude/plugins/cache/growthx-marketplace/superset/0.1.4","source":"superset@growthx-marketplace"},{"name":"gopls","path":"/Users/zhangruofan/.claude/plugins/cache/claude-code-lsps/gopls/0.1.0","source":"gopls@claude-code-lsps"},{"name":"microservice-manifest-spec","path":"/Users/zhangruofan/Skills/microservice-manifest-spec","source":"microservice-manifest-spec@local-skills"},{"name":"rust-analyzer-lsp","path":"/Users/zhangruofan/.claude/plugins/cache/claude-plugins-official/rust-analyzer-lsp/1.0.0","source":"rust-analyzer-lsp@claude-plugins-official"}],"analytics_disabled":true,"product_feedback_disabled":false,"uuid":"00fd8257-44b3-45f5-b1bf-18741f3e5519","memory_paths":{"auto":"/Users/zhangruofan/.claude/projects/-private-tmp-claude-subagent-probe/memory/"},"fast_mode_state":"off"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_wzvrarotnmqqeqee6tqp2jteqsrczxiyzeyvwfljcev5wgf36miq","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"Et4FCmMIDhABGAIqQFoD+cTm4bQprzx21uBxtzR0djzWoZcLk/kGpeiY398mLAuqcDH3BTB8tN66aV+7uVcor9gHfVrzcJJVKii/DFkyDWNsYXVkZS1xdWluY2U4AEIIdGhpbmtpbmcSDL6hAos+yd8QCzoVQhoMfmkfPglF0ftOzdaSIjBU+HTq//vb4u/stwi5A3mrHhNnBX+nJPnj1VSUB3eaiHOsHctSMRpM2pd8JHf0bX4qqAQQNPkmXfUZGC4YjWsz0umDB/LWJwpQ3+fn2VHstQwOeadGuigsdudzMZHOVSXnTqnbo5gYjhLR2fNq84u+88BAuo38NfckPJIlGNPF9kEClG6NaY3BZpU8gWit2n4QbgLJOWoQyoB63UdPa4VtaajISXPoVIxj7GK2QnqeIvrkWA5/XZGLc69Tbf/enVFtrk8hJ7GBb6BG0qkk7KVre9FEX5gK3uwM9UcAszKPvoxWKrUuakD5ojL/ilguZ7D2xi9zA07fIRyLE33ifFMEEC3f9/Eauxp0hpN/737X9q15WyW38RZNeKfswtkg7kVzZ+irVdKzCcTD/R10xZVsCi3RFadhvUBu3lV3GBEZazLRZthBC7wiaXZGxeIW+eyEhXD7M9yM53fKTquuRGorX1t0mo4Fg2VUgjZP2UvSMyY2xicjivD1M9pX/uPJ89D26b0pv9yj4hzwNwkled7fw6xM1yQY8pgSSydvnHIug3yi7CqR22aEHyL718nAPtUvSTOlK1G625cD74AguEHxsQAHP4OMejxSRfnkQBgnHrW5l6u2We5/tzxAZ4UBitfwy6OM/OGcx018pO4DAbTUX/mNltd0/lvCTQCD7fUrCNaCSobmjmykc4uxg3WzSkeNMbtGx77hGubV6wE39+sBhl8MFN6PPukC1Q0bkVJkYPp7ecty2OWJM345wJRM8LiIgxFLHzyqxo+HNU3SzS0MJsobDli3xkgSsZYYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3599,"cache_creation_input_tokens":78,"cache_read_input_tokens":16589,"cache_creation":{"ephemeral_5m_input_tokens":78,"ephemeral_1h_input_tokens":0},"output_tokens":30,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae","uuid":"9b5d69c3-5553-4512-bf67-c848358b23ac"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_wzvrarotnmqqeqee6tqp2jteqsrczxiyzeyvwfljcev5wgf36miq","type":"message","role":"assistant","content":[{"type":"text","text":"I'll launch a general-purpose subagent to run that command."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3599,"cache_creation_input_tokens":78,"cache_read_input_tokens":16589,"cache_creation":{"ephemeral_5m_input_tokens":78,"ephemeral_1h_input_tokens":0},"output_tokens":30,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae","uuid":"7c770b19-5b73-4533-a6a0-94af2160d45d"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_wzvrarotnmqqeqee6tqp2jteqsrczxiyzeyvwfljcev5wgf36miq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_bdrk_01AnD5Af6r9vYWvADBW8tCqt","name":"Agent","input":{"description":"Run echo command","prompt":"Run exactly this bash command: echo hello-from-subagent\n\nThen report back the exact stdout output it produced. Do not run any other commands.","subagent_type":"general-purpose"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3599,"cache_creation_input_tokens":78,"cache_read_input_tokens":16589,"cache_creation":{"ephemeral_5m_input_tokens":78,"ephemeral_1h_input_tokens":0},"output_tokens":30,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae","uuid":"2b0af902-5336-4d89-9dc4-d757051066bc"} +{"type":"system","subtype":"task_started","task_id":"a4135f81cdb8fabf2","tool_use_id":"toolu_bdrk_01AnD5Af6r9vYWvADBW8tCqt","description":"Run echo command","subagent_type":"general-purpose","task_type":"local_agent","prompt":"Run exactly this bash command: echo hello-from-subagent\n\nThen report back the exact stdout output it produced. Do not run any other commands.","uuid":"1ba4a27c-6439-459d-b439-69c3582b6f9f","session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae"} +{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Run exactly this bash command: echo hello-from-subagent\n\nThen report back the exact stdout output it produced. Do not run any other commands."}]},"parent_tool_use_id":"toolu_bdrk_01AnD5Af6r9vYWvADBW8tCqt","session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae","uuid":"dcd8b432-3e08-4125-9a6c-c5e4da6f313c","timestamp":"2026-06-09T06:05:05.857Z","subagent_type":"general-purpose","task_description":"Run echo command"} +{"type":"system","subtype":"task_progress","task_id":"a4135f81cdb8fabf2","tool_use_id":"toolu_bdrk_01AnD5Af6r9vYWvADBW8tCqt","description":"Running Echo a test string","subagent_type":"general-purpose","usage":{"total_tokens":8317,"tool_uses":1,"duration_ms":3683},"last_tool_name":"Bash","uuid":"5ca6b92a-8230-448a-a50c-a9d4d0be5e69","session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_nl6psj74ni5frcy37t4bj7xknovjet7esfxlq3hoyd3vjebpxjda","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_bdrk_0166fNHhKAYjdkY8rWSCjjzd","name":"Bash","input":{"command":"echo hello-from-subagent","description":"Echo a test string"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3462,"cache_creation_input_tokens":45,"cache_read_input_tokens":4802,"cache_creation":{"ephemeral_5m_input_tokens":45,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":"toolu_bdrk_01AnD5Af6r9vYWvADBW8tCqt","session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae","uuid":"3eaab401-b67a-43d7-8e31-4df03f6f18b0","subagent_type":"general-purpose","task_description":"Run echo command"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_bdrk_0166fNHhKAYjdkY8rWSCjjzd","type":"tool_result","content":"hello-from-subagent","is_error":false}]},"parent_tool_use_id":"toolu_bdrk_01AnD5Af6r9vYWvADBW8tCqt","session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae","uuid":"473476b3-6bcd-41e9-ac79-1589d11c8dd5","timestamp":"2026-06-09T06:05:09.872Z","subagent_type":"general-purpose","task_description":"Run echo command"} +{"type":"system","subtype":"task_notification","task_id":"a4135f81cdb8fabf2","tool_use_id":"toolu_bdrk_01AnD5Af6r9vYWvADBW8tCqt","status":"completed","output_file":"","summary":"Run echo command","usage":{"total_tokens":8425,"tool_uses":1,"duration_ms":6438},"uuid":"43c1ead5-5b76-4ee4-9b8c-bfa3ee13d4c6","session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_bdrk_01AnD5Af6r9vYWvADBW8tCqt","type":"tool_result","content":[{"type":"text","text":"The command `echo hello-from-subagent` produced this exact stdout:\n\n```\nhello-from-subagent\n```"},{"type":"text","text":"agentId: a4135f81cdb8fabf2 (use SendMessage with to: 'a4135f81cdb8fabf2' to continue this agent)\nsubagent_tokens: 8454\ntool_uses: 1\nduration_ms: 6440"}]}]},"parent_tool_use_id":null,"session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae","uuid":"a3e0a31f-ef85-4702-9447-60c1daf45fa3","timestamp":"2026-06-09T06:05:12.304Z","tool_use_result":{"status":"completed","prompt":"Run exactly this bash command: echo hello-from-subagent\n\nThen report back the exact stdout output it produced. Do not run any other commands.","agentId":"a4135f81cdb8fabf2","agentType":"general-purpose","content":[{"type":"text","text":"The command `echo hello-from-subagent` produced this exact stdout:\n\n```\nhello-from-subagent\n```"}],"totalDurationMs":6440,"totalTokens":8454,"totalToolUseCount":1,"usage":{"input_tokens":2,"cache_creation_input_tokens":3567,"cache_read_input_tokens":4847,"output_tokens":38,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":3567},"inference_geo":"","iterations":[],"speed":"standard"},"toolStats":{"readCount":0,"searchCount":0,"bashCount":1,"editFileCount":0,"linesAdded":0,"linesRemoved":0,"otherToolCount":0}}} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_clfwdm547xzuapamysnuxmmsiif6qlo5r343nl23dkswp3gxjfiq","type":"message","role":"assistant","content":[{"type":"text","text":"The subagent ran `echo hello-from-subagent` and returned:\n\n```\nhello-from-subagent\n```"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3895,"cache_read_input_tokens":16667,"cache_creation":{"ephemeral_5m_input_tokens":3895,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae","uuid":"74282a5a-ed0b-424e-9490-a332b046ea95"} +{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":16299,"duration_api_ms":15941,"ttft_ms":5298,"ttft_stream_ms":3274,"time_to_request_ms":34,"num_turns":2,"result":"The subagent ran `echo hello-from-subagent` and returned:\n\n```\nhello-from-subagent\n```","stop_reason":"end_turn","session_id":"f4477e38-4278-4842-9b5b-efb972e9fdae","total_cost_usd":0.11733374999999999,"usage":{"input_tokens":3601,"cache_creation_input_tokens":3973,"cache_read_input_tokens":33256,"output_tokens":394,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":3973},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{"global.anthropic.claude-opus-4-8":{"inputTokens":7065,"outputTokens":526,"cacheReadInputTokens":42905,"cacheCreationInputTokens":7585,"webSearchRequests":0,"costUSD":0.11733374999999999,"contextWindow":200000,"maxOutputTokens":64000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"0eee37ce-c3c0-4dfc-ac96-6140edd3d07a"} diff --git a/crates/aionui-session/tests/fixtures/claude_2.1.176_workflow_multiagent_3parallel_1fail.ndjson b/crates/aionui-session/tests/fixtures/claude_2.1.176_workflow_multiagent_3parallel_1fail.ndjson new file mode 100644 index 000000000..8a029c9fe --- /dev/null +++ b/crates/aionui-session/tests/fixtures/claude_2.1.176_workflow_multiagent_3parallel_1fail.ndjson @@ -0,0 +1,69 @@ +{"type":"system","subtype":"hook_started","hook_id":"fccd7984-1773-45cb-910b-fc20ea424767","hook_name":"SessionStart:startup","hook_event":"SessionStart","uuid":"d81d2f6c-6727-4399-9799-d83e2127c381","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"hook_response","hook_id":"fccd7984-1773-45cb-910b-fc20ea424767","hook_name":"SessionStart:startup","hook_event":"SessionStart","output":"","stdout":"","stderr":"","exit_code":0,"outcome":"success","uuid":"413dfb44-e414-457c-abee-5c90bfd82a9c","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"init","cwd":"/private/tmp","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","ListMcpResourcesTool","LSP","NotebookEdit","Read","ReadMcpResourceTool","ScheduleWakeup","SendMessage","Skill","TaskCreate","TaskGet","TaskList","TaskOutput","TaskStop","TaskUpdate","TeamCreate","TeamDelete","ToolSearch","WebFetch","Workflow","Write","mcp__argocd-dev__authenticate","mcp__argocd-dev__complete_authentication","mcp__atlassian__addCommentToJiraIssue","mcp__atlassian__addWorklogToJiraIssue","mcp__atlassian__atlassianUserInfo","mcp__atlassian__createConfluenceFooterComment","mcp__atlassian__createConfluenceInlineComment","mcp__atlassian__createConfluencePage","mcp__atlassian__createIssueLink","mcp__atlassian__createJiraIssue","mcp__atlassian__editJiraIssue","mcp__atlassian__fetch","mcp__atlassian__getAccessibleAtlassianResources","mcp__atlassian__getConfluenceCommentChildren","mcp__atlassian__getConfluencePage","mcp__atlassian__getConfluencePageDescendants","mcp__atlassian__getConfluencePageFooterComments","mcp__atlassian__getConfluencePageInlineComments","mcp__atlassian__getConfluenceSpaces","mcp__atlassian__getIssueLinkTypes","mcp__atlassian__getJiraIssue","mcp__atlassian__getJiraIssueRemoteIssueLinks","mcp__atlassian__getJiraIssueTypeMetaWithFields","mcp__atlassian__getJiraProjectIssueTypesMetadata","mcp__atlassian__getPagesInConfluenceSpace","mcp__atlassian__getTransitionsForJiraIssue","mcp__atlassian__getVisibleJiraProjects","mcp__atlassian__lookupJiraAccountId","mcp__atlassian__search","mcp__atlassian__searchConfluenceUsingCql","mcp__atlassian__searchJiraIssuesUsingJql","mcp__atlassian__transitionJiraIssue","mcp__atlassian__updateConfluencePage","mcp__plugin_aliyun-observability_aliyun-observability__cms_execute_promql_query","mcp__plugin_aliyun-observability_aliyun-observability__sls_describe_logstore","mcp__plugin_aliyun-observability_aliyun-observability__sls_diagnose_query","mcp__plugin_aliyun-observability_aliyun-observability__sls_execute_sql_query","mcp__plugin_aliyun-observability_aliyun-observability__sls_get_current_time","mcp__plugin_aliyun-observability_aliyun-observability__sls_get_regions","mcp__plugin_aliyun-observability_aliyun-observability__sls_list_logstores","mcp__plugin_aliyun-observability_aliyun-observability__sls_list_projects","mcp__plugin_superset_rc-superset__authenticate","mcp__plugin_superset_rc-superset__complete_authentication","mcp__plugin_superset_superset-v2__authenticate","mcp__plugin_superset_superset-v2__complete_authentication","mcp__stardust__create_pipeline","mcp__stardust__get_application","mcp__stardust__get_chart","mcp__stardust__get_cluster","mcp__stardust__get_execution","mcp__stardust__get_execution_logs","mcp__stardust__get_pipeline","mcp__stardust__get_template","mcp__stardust__get_template_version","mcp__stardust__list_applications","mcp__stardust__list_branches","mcp__stardust__list_charts","mcp__stardust__list_clusters","mcp__stardust__list_environments","mcp__stardust__list_executions","mcp__stardust__list_pipelines","mcp__stardust__list_tags","mcp__stardust__list_template_versions","mcp__stardust__list_templates","mcp__stardust__star_pipeline","mcp__stardust__trigger_build"],"mcp_servers":[{"name":"plugin:aliyun-observability:aliyun-observability","status":"connected"},{"name":"plugin:pionex-crm:pionex-crm","status":"failed"},{"name":"plugin:superset:rc-superset","status":"needs-auth"},{"name":"plugin:superset:superset-v2","status":"needs-auth"},{"name":"aliyun-observability","status":"failed"},{"name":"atlassian","status":"connected"},{"name":"stardust","status":"connected"},{"name":"argocd-dev","status":"needs-auth"}],"model":"global.anthropic.claude-opus-4-8","permissionMode":"bypassPermissions","slash_commands":["deep-research","polymarket-api:polymarket-api","superset:superset-guide","microservice-manifest-spec:manifest-init","microservice-manifest-spec:manifest-spec","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","loop","claude-api","run","run-skill-generator","clear","compact","context","heapdump","init","reload-skills","review","security-review","usage","insights","goal","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.177","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["deep-research","polymarket-api:polymarket-api","superset:superset-guide","microservice-manifest-spec:manifest-init","microservice-manifest-spec:manifest-spec","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","loop","claude-api","run","run-skill-generator"],"plugins":[{"name":"polymarket-api","path":"/Users/zhangruofan/.claude/plugins/cache/future-skills/polymarket-api/1.0.0","source":"polymarket-api@future-skills"},{"name":"aliyun-observability","path":"/Users/zhangruofan/.claude/plugins/cache/growthx-marketplace/aliyun-observability/0.1.0","source":"aliyun-observability@growthx-marketplace"},{"name":"pionex-crm","path":"/Users/zhangruofan/.claude/plugins/cache/growthx-marketplace/pionex-crm/0.1.0","source":"pionex-crm@growthx-marketplace"},{"name":"superset","path":"/Users/zhangruofan/.claude/plugins/cache/growthx-marketplace/superset/0.1.4","source":"superset@growthx-marketplace"},{"name":"gopls","path":"/Users/zhangruofan/.claude/plugins/cache/claude-code-lsps/gopls/0.1.0","source":"gopls@claude-code-lsps"},{"name":"microservice-manifest-spec","path":"/Users/zhangruofan/Skills/microservice-manifest-spec","source":"microservice-manifest-spec@local-skills"},{"name":"rust-analyzer-lsp","path":"/Users/zhangruofan/.claude/plugins/cache/claude-plugins-official/rust-analyzer-lsp/1.0.0","source":"rust-analyzer-lsp@claude-plugins-official"}],"analytics_disabled":true,"product_feedback_disabled":false,"uuid":"9ec9598c-8e5d-4dff-9610-4849c1df7fc2","memory_paths":{"auto":"/Users/zhangruofan/.claude/projects/-private-tmp/memory/"},"fast_mode_state":"off"} +{"type":"user","message":{"role":"user","content":[{"type":"text","text":"用 Workflow 工具起一个 workflow,要求:phase1 并行派 3 个 agent A/B/C —— A 执行 `sleep 8 && echo A_OK`,B 执行 `sleep 12 && echo B_OK`,C 执行 `exit 1`(故意失败)。phase2 再派 1 个 agent D 汇总。用 run_in_background 让它们真并行。现在就调 Workflow 工具执行。"}]},"session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","parent_tool_use_id":null,"uuid":"0b08708c-47eb-4336-b9ec-0df54f24be3a","timestamp":"2026-06-13T06:48:26.028Z","isReplay":true} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_x6ltbcbhtop7iaqmwhyewfn24caqfissuwuvnnl37akc4zilc7za","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"Es4MCmMIDhABGAIqQOTs6BqpK0fy8u90SKTUIwRQMI03uEvkElsNP60SdujWrqm1noqya0ukDkmqtm67M7ad2Cx1SKFlIrH91jWU6vAyDWNsYXVkZS1xdWluY2U4AEIIdGhpbmtpbmcSDJmFEm1aaGfbeNRvVBoM1QtYnYtVy7UGpZmRIjDN6iZDdWKK1HW4gkqV3VbUyfhOFEBHkwxvhnfs3PAN+e9sq3Afer2y3xEhWiSctbwqmAtz1VgFeaapO8t/CAJnPXRKpQb6fKb9c/m5Gq4dZMCRckvSQNuo4+bjPwGFDiSE7D8vAd6LxLOdtRBGLMj0kibFHI7K+FzEYbDQUoPeBAB1PI5X2v74Cs3jFD8w27ujSo1o3+7hChlub+/NAYmXMnbtxbzS4gdBUW74on13WzQQpuT9YQFOx5xMpL1ZEX+idO4ziwnscLl1/aJg/H28OLHe+e5kQP6jIj1Eh8IhBjk66P9dw4ppB9sZ9NKdTBPLxnf9Eh1pM8kzgzIxgE7O+1TDpsfLEiJYlEb2GvSG5ywb5Dk0zt0ZhSi8zVUEBdK8mVhHGOK8RTJIfxgRNjE6SQok0ouH6/E4y7NN9SrKjCT4N8H2bcsHca97VskNw/UYo5k9TjEeL5/2WxQmsjVYZtBdBkGQclxBs6n6if+FHZ9y3slaoQf53MNnI2e7FXu93rT/9cwd7RnILwfCjPAOeERZpunTnUpedk+8oD/8emUyw7g2V3WiAzu71p/H5mhGi9qjdpXyK8lHJEA2DWs/cUmP+9Jo7008tWqd4BSP+D/XN2S3rWFfCAtwxbaGVyfS3gUeFyo/CsNUOvdZFu5xKzxOX8EbsawFj3OxhAaBYiC+9DXBvGorNdflLjx6Xlc9KmelfD7cEf9e1V1O1o8I9gLex360LxIxxNQX17xjGyY2nkDQ/zRPOo2xTPmSJKLWEEbXiAqy1VvhLrxyF+ln61w18HTtb4bQnq72L/8bHDYdJXfpt4yZq9UDGDR+eStIt+eh08uyi3+3x+rmdRceHzMf8ZEa6bRXGZSPvuTeuMWWhRuqXw1kJFPAEbR29kTJHF6QQj7SJ1EjBFyz7hSpTcCmwD7hp4TM8mPoCntkqyfmtxZSypgdJEVDzMvCDPD81WBxl+KAT0g4XuHJklI+lv9uw1yNMz0EcZBDO68TMlJEnMOWfEtFlgsxqJjoFNiWJ41YK9Oh6wACNfVbtXXt/vBJtFjFzuLV4PmOgJy2ZJOZUR+47zGQw6WxgobEPeOGS+ouC895fdbDcN+5vYhyU20c+J4zmpShQDeH6Ps+/4MKtT87j8nE0sprrYLcOWirbxKhEXss4HUM21HdSabQCTYWySRyjiWHylpyz2Mes2wZEPTiKvY2ctdFMHOGJ6T7+jarKMlrjG2AXEtOE2eginq/L1STEeBVSeHm1rzYoOqG0dp2JyawMtopU9Hjy5j0AIQUEUaOVHPdDY9QfleSx+AQbl8UKCLLPPDUQDSaAy0Z4rF6I7WYcji5+1dihi71pK9jIXpV234lJah5R8Ww0vwaJbYi0EKvxRbQGE0HkYWinu63c5lEVXebF/5z0WtcutfHSjvQkr2sSr+RXylLcZcP6L7hsn2uC1qq5jx6oLNVoVzFwO8Upv0DuyBLoH22HDKBr4ORr3c4C/kx9lzKk6elqK72wDQNKXQlPt6fD0cRqF3pkj5xydEVOz2eYPa45VtfnDkpLVyOeRLUBbNbByb+7yYmSFsETZvqy0RC5q9Dbci8yGKqgjYzwcoD1OUXz1aCwFCfJq7fiIXzDrIsq4ZXqjyPhaJPZrpdloph3aVzVbUlbs3ohfX3cNaN8IB+uKbilqfcuCpCMe1GO4mXMqDENTUfDrIdDgqIV2VKXL6A3uCqdHcS6aXA6bT98eFWJdd40HYUzeULRuO8aEdtXHb/wUULHHzvwv22e4aaFt+803xtH5qAU0lGSXaWmjITA9y7t8Byk2yFy3gWMktW9WPQLt8kZBfqjFnrN2yICjZjCaNwEjXjtrR6gabo+VDW8z0eT71xMQxc+GfEVR+Ntj6RG2yZMF69T1ZW1LjUrE+AYTmarsaH7zUeYIiXbssaehG1BUw4cMrvWnv/sjqDkIXFqlPikGDWTpGij2Abd5+hsTjIfxv4K+7qGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3621,"cache_creation_input_tokens":16759,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":16759,"ephemeral_1h_input_tokens":0},"output_tokens":25,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","uuid":"1bd9981f-6020-49d4-b2c1-daaf7dd37cfa"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_x6ltbcbhtop7iaqmwhyewfn24caqfissuwuvnnl37akc4zilc7za","type":"message","role":"assistant","content":[{"type":"text","text":"I'll create and run the workflow now."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3621,"cache_creation_input_tokens":16759,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":16759,"ephemeral_1h_input_tokens":0},"output_tokens":25,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","uuid":"86017531-fd5e-47ea-a461-9cdaa0210643"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_x6ltbcbhtop7iaqmwhyewfn24caqfissuwuvnnl37akc4zilc7za","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","name":"Workflow","input":{"script":"export const meta = {\n name: 'parallel-bg-demo',\n description: 'Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes',\n phases: [\n { title: 'Run', detail: 'A: sleep 8; B: sleep 12; C: exit 1 — concurrent via run_in_background' },\n { title: 'Summarize', detail: 'agent D aggregates the three results' },\n ],\n}\n\nconst RESULT_SCHEMA = {\n type: 'object',\n additionalProperties: false,\n required: ['name', 'command', 'exitCode', 'stdout', 'succeeded'],\n properties: {\n name: { type: 'string', description: 'Agent name: A, B, or C' },\n command: { type: 'string', description: 'The exact shell command that was run' },\n exitCode: { type: 'integer', description: 'Process exit code' },\n stdout: { type: 'string', description: 'Captured stdout (empty string if none)' },\n succeeded: { type: 'boolean', description: 'true if exitCode == 0' },\n },\n}\n\nconst JOBS = [\n { name: 'A', cmd: 'sleep 8 && echo A_OK' },\n { name: 'B', cmd: 'sleep 12 && echo B_OK' },\n { name: 'C', cmd: 'exit 1' },\n]\n\nphase('Run')\nconst results = await parallel(JOBS.map((j) => () =>\n agent(\n `You are agent ${j.name} in a parallelism demo.\\n` +\n `Run EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\\n\\n` +\n ` ${j.cmd}\\n\\n` +\n `Steps:\\n` +\n `1. Call Bash with that exact command and run_in_background: true.\\n` +\n `2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\\n` +\n `3. Do NOT treat a non-zero exit code as a failure of your own task — report it faithfully. Command \"${j.cmd}\" may intentionally fail (e.g. exit 1).\\n` +\n `Return the structured result describing what happened.`,\n { label: `run:${j.name}`, phase: 'Run', schema: RESULT_SCHEMA }\n ).then((r) => r || { name: j.name, command: j.cmd, exitCode: -1, stdout: '', succeeded: false })\n))\n\nphase('Summarize')\nconst summary = await agent(\n `You are agent D, the aggregator. Three sibling agents ran shell commands concurrently. Here are their structured results as JSON:\\n\\n` +\n JSON.stringify(results, null, 2) +\n `\\n\\nWrite a concise human-readable summary table covering each agent (A/B/C): the command, its exit code, stdout, and whether it succeeded. ` +\n `Explicitly note that C was expected to fail (exit 1). End with a one-line overall verdict (e.g. \"2/3 succeeded, C failed as intended\").`,\n { label: 'summarize:D', phase: 'Summarize' }\n)\n\nreturn { results, summary }\n"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3621,"cache_creation_input_tokens":16759,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":16759,"ephemeral_1h_input_tokens":0},"output_tokens":25,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","uuid":"59363643-a028-4243-a16d-bd539c90ffd8"} +{"type":"system","subtype":"task_started","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","task_type":"local_workflow","workflow_name":"parallel-bg-demo","prompt":"export const meta = {\n name: 'parallel-bg-demo',\n description: 'Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes',\n phases: [\n { title: 'Run', detail: 'A: sleep 8; B: sleep 12; C: exit 1 — concurrent via run_in_background' },\n { title: 'Summarize', detail: 'agent D aggregates the three results' },\n ],\n}\n\nconst RESULT_SCHEMA = {\n type: 'object',\n additionalProperties: false,\n required: ['name', 'command', 'exitCode', 'stdout', 'succeeded'],\n properties: {\n name: { type: 'string', description: 'Agent name: A, B, or C' },\n command: { type: 'string', description: 'The exact shell command that was run' },\n exitCode: { type: 'integer', description: 'Process exit code' },\n stdout: { type: 'string', description: 'Captured stdout (empty string if none)' },\n succeeded: { type: 'boolean', description: 'true if exitCode == 0' },\n },\n}\n\nconst JOBS = [\n { name: 'A', cmd: 'sleep 8 && echo A_OK' },\n { name: 'B', cmd: 'sleep 12 && echo B_OK' },\n { name: 'C', cmd: 'exit 1' },\n]\n\nphase('Run')\nconst results = await parallel(JOBS.map((j) => () =>\n agent(\n `You are agent ${j.name} in a parallelism demo.\\n` +\n `Run EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\\n\\n` +\n ` ${j.cmd}\\n\\n` +\n `Steps:\\n` +\n `1. Call Bash with that exact command and run_in_background: true.\\n` +\n `2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\\n` +\n `3. Do NOT treat a non-zero exit code as a failure of your own task — report it faithfully. Command \"${j.cmd}\" may intentionally fail (e.g. exit 1).\\n` +\n `Return the structured result describing what happened.`,\n { label: `run:${j.name}`, phase: 'Run', schema: RESULT_SCHEMA }\n ).then((r) => r || { name: j.name, command: j.cmd, exitCode: -1, stdout: '', succeeded: false })\n))\n\nphase('Summarize')\nconst summary = await agent(\n `You are agent D, the aggregator. Three sibling agents ran shell commands concurrently. Here are their structured results as JSON:\\n\\n` +\n JSON.stringify(results, null, 2) +\n `\\n\\nWrite a concise human-readable summary table covering each agent (A/B/C): the command, its exit code, stdout, and whether it succeeded. ` +\n `Explicitly note that C was expected to fail (exit 1). End with a one-line overall verdict (e.g. \"2/3 succeeded, C failed as intended\").`,\n { label: 'summarize:D', phase: 'Summarize' }\n)\n\nreturn { results, summary }\n","uuid":"067556b9-f8e0-4121-a7ff-aaa50a388a0d","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","usage":{"total_tokens":0,"tool_uses":0,"duration_ms":64},"summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_phase","index":1,"title":"Run"},{"type":"workflow_phase","index":2,"title":"Summarize"}],"uuid":"ca94ae54-ae09-4f97-beda-195d685202a0","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","type":"tool_result","content":"Workflow launched in background. Task ID: wanv3yy20\nSummary: Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes\nTranscript dir: /Users/zhangruofan/.claude/projects/-private-tmp/8e57e34a-bc88-4d0c-a664-eac6c31d684b/subagents/workflows/wf_a78f02af-e02\nScript file: /Users/zhangruofan/.claude/projects/-private-tmp/8e57e34a-bc88-4d0c-a664-eac6c31d684b/workflows/scripts/parallel-bg-demo-wf_a78f02af-e02.js\n(Edit this file with Write/Edit and re-invoke Workflow with {scriptPath: \"/Users/zhangruofan/.claude/projects/-private-tmp/8e57e34a-bc88-4d0c-a664-eac6c31d684b/workflows/scripts/parallel-bg-demo-wf_a78f02af-e02.js\"} to iterate without resending the script.)\nRun ID: wf_a78f02af-e02\nTo resume after editing the script: Workflow({scriptPath: \"/Users/zhangruofan/.claude/projects/-private-tmp/8e57e34a-bc88-4d0c-a664-eac6c31d684b/workflows/scripts/parallel-bg-demo-wf_a78f02af-e02.js\", resumeFromRunId: \"wf_a78f02af-e02\"}) — completed agents return cached results.\n\nYou will be notified when it completes. Use /workflows to watch live progress.","is_error":false}]},"parent_tool_use_id":null,"session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","uuid":"9b1d853b-6d48-4333-9534-7aa4c4a174ae","timestamp":"2026-06-13T06:48:49.349Z","tool_use_result":{"status":"async_launched","taskId":"wanv3yy20","taskType":"local_workflow","workflowName":"parallel-bg-demo","runId":"wf_a78f02af-e02","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","transcriptDir":"/Users/zhangruofan/.claude/projects/-private-tmp/8e57e34a-bc88-4d0c-a664-eac6c31d684b/subagents/workflows/wf_a78f02af-e02","scriptPath":"/Users/zhangruofan/.claude/projects/-private-tmp/8e57e34a-bc88-4d0c-a664-eac6c31d684b/workflows/scripts/parallel-bg-demo-wf_a78f02af-e02.js"}} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:C","usage":{"total_tokens":0,"tool_uses":0,"duration_ms":99},"last_tool_name":"run:C","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","model":"global.anthropic.claude-opus-4-8","state":"start","queuedAt":1781333329316,"promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333329316},{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","model":"global.anthropic.claude-opus-4-8","state":"start","queuedAt":1781333329316,"promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333329316},{"type":"workflow_agent","index":3,"label":"run:C","phaseIndex":1,"phaseTitle":"Run","model":"global.anthropic.claude-opus-4-8","state":"start","queuedAt":1781333329316,"promptPreview":"You are agent C in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n exit 1\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-zero exit code a…","lastProgressAt":1781333329316},{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"start","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333329335},{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"start","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333329337},{"type":"workflow_agent","index":3,"label":"run:C","phaseIndex":1,"phaseTitle":"Run","agentId":"ad3b9d7c1ceb0490f","model":"global.anthropic.claude-opus-4-8","state":"start","startedAt":1781333329338,"queuedAt":1781333329316,"attempt":1,"promptPreview":"You are agent C in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n exit 1\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-zero exit code a…","lastProgressAt":1781333329338}],"uuid":"ed26c128-9631-470f-8bd0-870a1b64dfc6","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":8576,"tool_uses":0,"duration_ms":3438},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333332671,"tokens":8576,"toolCalls":0}],"uuid":"089d33e3-987f-42a2-88f7-9f7c52482c4c","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":8576,"tool_uses":1,"duration_ms":3548},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Bash","lastToolSummary":"sleep 8 && echo A_OK","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333332769,"tokens":8576,"toolCalls":1}],"uuid":"eafdf494-fe5c-4782-b189-810c335be6d4","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_started","task_id":"be780odli","tool_use_id":"toolu_bdrk_01GKotPmSThehicz7o9DF5VM","description":"Run sleep then echo A_OK detached","task_type":"local_bash","uuid":"fea7bd74-51dc-4e7d-a712-dbda39befdfb","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:C","usage":{"total_tokens":17134,"tool_uses":1,"duration_ms":4434},"last_tool_name":"run:C","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":3,"label":"run:C","phaseIndex":1,"phaseTitle":"Run","agentId":"ad3b9d7c1ceb0490f","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329338,"queuedAt":1781333329316,"attempt":1,"promptPreview":"You are agent C in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n exit 1\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-zero exit code a…","lastProgressAt":1781333333669,"tokens":8558,"toolCalls":0}],"uuid":"353fa189-47a7-40d7-ae66-0cc96568f6fb","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:C","usage":{"total_tokens":17134,"tool_uses":2,"duration_ms":4490},"last_tool_name":"run:C","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":3,"label":"run:C","phaseIndex":1,"phaseTitle":"Run","agentId":"ad3b9d7c1ceb0490f","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329338,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Bash","lastToolSummary":"exit 1","promptPreview":"You are agent C in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n exit 1\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-zero exit code a…","lastProgressAt":1781333333723,"tokens":8558,"toolCalls":1}],"uuid":"f738b49b-4b67-4c42-96d6-112d03051c06","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_started","task_id":"bgw0rnxcj","tool_use_id":"toolu_bdrk_01Nz3ueG4mPQJhGxCGELL2w3","description":"Run exit 1 detached in background","task_type":"local_bash","uuid":"a1e449be-215b-4372-93cd-47ce104c3661","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_updated","task_id":"bgw0rnxcj","patch":{"status":"failed","end_time":1781333333754},"uuid":"153453b0-8632-4015-894a-e6e32cf2806b","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_notification","task_id":"bgw0rnxcj","tool_use_id":"toolu_bdrk_01Nz3ueG4mPQJhGxCGELL2w3","status":"failed","output_file":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a664-eac6c31d684b/tasks/bgw0rnxcj.output","summary":"Background command \"Run exit 1 detached in background\" failed with exit code 1","uuid":"f66f9432-fb94-49c6-bc6c-866559216916","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:B","usage":{"total_tokens":25710,"tool_uses":2,"duration_ms":4676},"last_tool_name":"run:B","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333333910,"tokens":8576,"toolCalls":0}],"uuid":"db67033b-8a85-4958-af2d-88dfd268ea99","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_started","task_id":"bu68ac6fo","tool_use_id":"toolu_bdrk_014sY6Zn7s7yyHiUsNvLNmdS","description":"Sleep 12 seconds then echo B_OK","task_type":"local_bash","uuid":"d64374a6-c1b6-4c52-b4bb-7a8133d88c63","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:B","usage":{"total_tokens":25710,"tool_uses":3,"duration_ms":4753},"last_tool_name":"run:B","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Bash","lastToolSummary":"sleep 12 && echo B_OK","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333333983,"tokens":8576,"toolCalls":1}],"uuid":"922354cf-f124-460f-b491-0d4760922fea","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_c4qbvniu42i25lqbo426spgc6sy2muukp35z6fihep7js2w22pfa","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"EsACCmMIDhABGAIqQOp3xhCdquG6Fmf8t7UO0u00Ax1lKrqMenge/mapfeitG1S/TE/CuGETI61p9fZXJySXcpmS2FhkCvv0sl2uzMEyDWNsYXVkZS1xdWluY2U4AEIIdGhpbmtpbmcSDIwJfiUYm15Jx9N5oBoMU2sj7Y3ll62ycciMIjBAaWwwdYbpqRAIFf8FpZLk4Vn8MAmYpDLwHkflEUL4C+euYpTfz2+KVrX3hCG4yuwqigHswHjWjvq1gRQlhiJAWqtvUuIH4aFSF+IQECY7h73v3S82abXE8wG43LQR75UVeLlksfZjcWtBm4A6SFUaRFiBH/TAJtiFC2lG46NbS0UiW4ozRX2jA3gOKeMdQYVWdoG1eEmWECds7TNErODK0o+XkCBTSEQvFLhNT/lQc7rmtYvDW4LgDg6/kwcYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5321,"cache_read_input_tokens":16759,"cache_creation":{"ephemeral_5m_input_tokens":5321,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","uuid":"37153f75-ea87-4ec6-87fe-282ba3f1dd62"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":25952,"tool_uses":3,"duration_ms":7328},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Bash","lastToolSummary":"sleep 8 && echo A_OK","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333336555,"tokens":8818,"toolCalls":1}],"uuid":"a9ae1af2-d4c5-4825-9b0a-70ccba9365b8","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":25952,"tool_uses":4,"duration_ms":7491},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"ToolSearch","lastToolSummary":"select:TaskGet,TaskOutput","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333336727,"tokens":8818,"toolCalls":2}],"uuid":"a7417ba5-0fde-4a6f-995f-6ccb1040f6f6","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:B","usage":{"total_tokens":26204,"tool_uses":4,"duration_ms":8215},"last_tool_name":"run:B","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Bash","lastToolSummary":"sleep 12 && echo B_OK","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333337447,"tokens":8828,"toolCalls":1}],"uuid":"85d814ab-fd2a-4ce3-9aa9-76fb24a1e80e","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:B","usage":{"total_tokens":26204,"tool_uses":5,"duration_ms":8271},"last_tool_name":"run:B","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"ToolSearch","lastToolSummary":"select:TaskGet,TaskOutput","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333337504,"tokens":8828,"toolCalls":2}],"uuid":"ec9a6e1b-768a-4d8f-904d-0b13d73aa353","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:C","usage":{"total_tokens":26446,"tool_uses":5,"duration_ms":8507},"last_tool_name":"run:C","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":3,"label":"run:C","phaseIndex":1,"phaseTitle":"Run","agentId":"ad3b9d7c1ceb0490f","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329338,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Bash","lastToolSummary":"exit 1","promptPreview":"You are agent C in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n exit 1\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-zero exit code a…","lastProgressAt":1781333337743,"tokens":8800,"toolCalls":1}],"uuid":"1d611097-a65a-4c3c-bca9-711a6ed98fc9","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:C","usage":{"total_tokens":26446,"tool_uses":6,"duration_ms":8591},"last_tool_name":"run:C","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":3,"label":"run:C","phaseIndex":1,"phaseTitle":"Run","agentId":"ad3b9d7c1ceb0490f","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329338,"queuedAt":1781333329316,"attempt":1,"lastToolName":"ToolSearch","lastToolSummary":"select:TaskGet,TaskOutput","promptPreview":"You are agent C in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n exit 1\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-zero exit code a…","lastProgressAt":1781333337824,"tokens":8800,"toolCalls":2}],"uuid":"7e550504-5d6d-43f7-8a0a-2bd6f9ce65dd","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":27057,"tool_uses":6,"duration_ms":10817},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"ToolSearch","lastToolSummary":"select:TaskGet,TaskOutput","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333340053,"tokens":9429,"toolCalls":2}],"uuid":"23e4736d-3a83-4c68-abde-da31ecae7fdc","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":27057,"tool_uses":7,"duration_ms":10888},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Read","lastToolSummary":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a66…","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333340124,"tokens":9429,"toolCalls":3}],"uuid":"94b2678b-a05b-46aa-a896-02868b15ebd4","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:B","usage":{"total_tokens":27679,"tool_uses":7,"duration_ms":11719},"last_tool_name":"run:B","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"ToolSearch","lastToolSummary":"select:TaskGet,TaskOutput","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333340953,"tokens":9450,"toolCalls":2}],"uuid":"25f0a9e5-558a-458a-8a49-4952b6d017e9","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:C","usage":{"total_tokens":28567,"tool_uses":7,"duration_ms":11817},"last_tool_name":"run:C","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":3,"label":"run:C","phaseIndex":1,"phaseTitle":"Run","agentId":"ad3b9d7c1ceb0490f","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329338,"queuedAt":1781333329316,"attempt":1,"lastToolName":"ToolSearch","lastToolSummary":"select:TaskGet,TaskOutput","promptPreview":"You are agent C in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n exit 1\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-zero exit code a…","lastProgressAt":1781333341053,"tokens":9688,"toolCalls":2}],"uuid":"602968d7-1701-48b0-8e52-e5c8a5c43313","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:C","usage":{"total_tokens":28567,"tool_uses":8,"duration_ms":11866},"last_tool_name":"run:C","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":3,"label":"run:C","phaseIndex":1,"phaseTitle":"Run","agentId":"ad3b9d7c1ceb0490f","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329338,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Read","lastToolSummary":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a66…","promptPreview":"You are agent C in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n exit 1\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-zero exit code a…","lastProgressAt":1781333341101,"tokens":9688,"toolCalls":3}],"uuid":"75edb89b-764b-4e30-8985-8fee567d53e5","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:B","usage":{"total_tokens":28567,"tool_uses":9,"duration_ms":12051},"last_tool_name":"run:B","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Read","lastToolSummary":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a66…","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333341287,"tokens":9450,"toolCalls":3}],"uuid":"87cf38ee-6975-4c16-bfa3-6ed15c4788c9","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_updated","task_id":"be780odli","patch":{"status":"completed","end_time":1781333341356},"uuid":"1ef7f790-f0f5-4ab2-a432-2b2ee3cf939e","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_notification","task_id":"be780odli","tool_use_id":"toolu_bdrk_01GKotPmSThehicz7o9DF5VM","status":"completed","output_file":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a664-eac6c31d684b/tasks/be780odli.output","summary":"Background command \"Run sleep then echo A_OK detached\" completed (exit code 0)","uuid":"e4ad3052-4412-416c-9a08-1efcf6704426","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":28769,"tool_uses":9,"duration_ms":13684},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Read","lastToolSummary":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a66…","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333342919,"tokens":9631,"toolCalls":3}],"uuid":"6c511299-8534-4c3d-891e-a8470f49b225","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":28769,"tool_uses":10,"duration_ms":13725},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Bash","lastToolSummary":"sleep 9","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333342961,"tokens":9631,"toolCalls":4}],"uuid":"da564d5c-7fc0-437b-a614-318949bf0306","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_c4qbvniu42i25lqbo426spgc6sy2muukp35z6fihep7js2w22pfa","type":"message","role":"assistant","content":[{"type":"text","text":"✅ Workflow 已启动并在后台运行。\n\n**结构:**\n- **Phase 1 「Run」** — `parallel()` 同时派发 3 个 agent,互不阻塞:\n - **A**: `sleep 8 && echo A_OK` → 预期成功\n - **B**: `sleep 12 && echo B_OK` → 预期成功\n - **C**: `exit 1` → 故意失败\n - 每个 agent 被要求用 `Bash` + `run_in_background: true` 跑命令、轮询直到退出,并**如实**上报退出码(不把 C 的非零退出当成自身失败)。结构化 schema 强制返回 `{name, command, exitCode, stdout, succeeded}`。\n- **Phase 2 「Summarize」** — 等 3 个结果齐了(`parallel` 是 barrier),再派 agent **D** 汇总成表格 + 总体结论。\n\n**运行信息:**\n- Task ID: `wanv3yy20`\n- Run ID: `wf_a78f02af-e02`\n\n完成后我会收到通知并把 D 的汇总结果转给你。想实时看进度可以用 `/workflows`。\n\n> 说明:这里有两层\"并行\"——workflow 的 `parallel()` 让 A/B/C 三个 agent 并发(墙钟约等于最慢的 B≈12s,而非 8+12+0 串行);每个 agent 内部再用 `run_in_background` 让 bash 进程脱离前台真正后台执行。C 用 `.then()` 兜底,即使 agent 异常也不会让整批 `parallel` 拿到 null 而漏报。\n\n等待完成通知中…"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5321,"cache_read_input_tokens":16759,"cache_creation":{"ephemeral_5m_input_tokens":5321,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","uuid":"7024e02f-fa83-4cb2-882d-22944a34e1bb"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:B","usage":{"total_tokens":28988,"tool_uses":10,"duration_ms":15046},"last_tool_name":"run:B","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Read","lastToolSummary":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a66…","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333344281,"tokens":9669,"toolCalls":3}],"uuid":"99d403dc-8894-4345-b6d9-11605ae5f64e","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:B","usage":{"total_tokens":28988,"tool_uses":11,"duration_ms":15162},"last_tool_name":"run:B","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Bash","lastToolSummary":"sleep 13","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333344398,"tokens":9669,"toolCalls":4}],"uuid":"6f6ef8ee-2243-438c-9bbe-572d08cd9f48","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:C","usage":{"total_tokens":29177,"tool_uses":11,"duration_ms":16234},"last_tool_name":"run:C","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":3,"label":"run:C","phaseIndex":1,"phaseTitle":"Run","agentId":"ad3b9d7c1ceb0490f","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329338,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Read","lastToolSummary":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a66…","promptPreview":"You are agent C in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n exit 1\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-zero exit code a…","lastProgressAt":1781333345469,"tokens":9877,"toolCalls":3}],"uuid":"3dde7536-c7f4-4773-8453-78a12d21e91c","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_started","task_id":"b0ow1kcxo","tool_use_id":"toolu_bdrk_019t9kkhMLn33DSWzcyZ824x","description":"Wait for background process to finish","task_type":"local_bash","uuid":"2f0c3052-63bb-43d2-bce0-0e6a658f69d3","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_updated","task_id":"bu68ac6fo","patch":{"status":"completed","end_time":1781333346015},"uuid":"77887410-9653-4956-a897-666620bdbec0","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_notification","task_id":"bu68ac6fo","tool_use_id":"toolu_bdrk_014sY6Zn7s7yyHiUsNvLNmdS","status":"completed","output_file":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a664-eac6c31d684b/tasks/bu68ac6fo.output","summary":"Background command \"Sleep 12 seconds then echo B_OK\" completed (exit code 0)","uuid":"6cf0cd1b-b9f8-4199-a6a4-56f55e6d7cb1","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:C","usage":{"total_tokens":29177,"tool_uses":12,"duration_ms":16941},"last_tool_name":"run:C","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":3,"label":"run:C","phaseIndex":1,"phaseTitle":"Run","agentId":"ad3b9d7c1ceb0490f","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329338,"queuedAt":1781333329316,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"exit 1","promptPreview":"You are agent C in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n exit 1\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-zero exit code a…","lastProgressAt":1781333346176,"tokens":9877,"toolCalls":4}],"uuid":"be0991dd-ba10-43cb-90a1-bea691a128c5","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_started","task_id":"bsa8n1ley","tool_use_id":"toolu_bdrk_01AEzSuYWHB7MuzPUP2RBUVi","description":"Wait for background process to finish","task_type":"local_bash","uuid":"7bae642f-c7ef-4df4-ad94-cec5f99f301e","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:C","usage":{"total_tokens":29407,"tool_uses":12,"duration_ms":20294},"last_tool_name":"run:C","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":3,"label":"run:C","phaseIndex":1,"phaseTitle":"Run","agentId":"ad3b9d7c1ceb0490f","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329338,"queuedAt":1781333329316,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"exit 1","promptPreview":"You are agent C in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n exit 1\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-zero exit code a…","lastProgressAt":1781333349522,"tokens":10107,"toolCalls":4}],"uuid":"b4b34430-9271-43ff-bac5-289b1003cbba","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:C","usage":{"total_tokens":29407,"tool_uses":12,"duration_ms":20331},"last_tool_name":"run:C","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":3,"label":"run:C","phaseIndex":1,"phaseTitle":"Run","agentId":"ad3b9d7c1ceb0490f","model":"global.anthropic.claude-opus-4-8","state":"done","startedAt":1781333329338,"queuedAt":1781333329316,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"exit 1","promptPreview":"You are agent C in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n exit 1\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-zero exit code a…","lastProgressAt":1781333349566,"tokens":10107,"toolCalls":4,"durationMs":20228,"resultPreview":"{\"name\":\"C\",\"command\":\"exit 1\",\"exitCode\":1,\"stdout\":\"\",\"succeeded\":false}"}],"uuid":"aa38029a-3843-446b-8bac-9cda293f285c","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_notification","task_id":"b0ow1kcxo","tool_use_id":"toolu_bdrk_019t9kkhMLn33DSWzcyZ824x","status":"completed","output_file":"","summary":"Wait for background process to finish","uuid":"90d51b7c-4ed3-42f0-9f45-6afb3ae5040f","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":29798,"tool_uses":12,"duration_ms":25829},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Bash","lastToolSummary":"sleep 9","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333355064,"tokens":10022,"toolCalls":4}],"uuid":"f4b78774-5fdb-4e45-9ce9-af3163ef5602","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":29798,"tool_uses":13,"duration_ms":25962},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Read","lastToolSummary":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a66…","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333355198,"tokens":10022,"toolCalls":5}],"uuid":"e6f277fc-f55c-4fd6-8727-b2626263a49c","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_notification","task_id":"bsa8n1ley","tool_use_id":"toolu_bdrk_01AEzSuYWHB7MuzPUP2RBUVi","status":"completed","output_file":"","summary":"Wait for background process to finish","uuid":"13a07cdf-98dc-454c-92f3-c1d41754c8fd","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":30072,"tool_uses":13,"duration_ms":29128},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Read","lastToolSummary":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a66…","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333358364,"tokens":10296,"toolCalls":5}],"uuid":"0ef27ab5-c7cd-4c05-8288-c8c17bf0ddc3","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":30072,"tool_uses":14,"duration_ms":29346},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"sleep 8 && echo A_OK","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333358582,"tokens":10296,"toolCalls":6}],"uuid":"b1c4b7ad-dfa9-43e5-beb6-0617af972dd6","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:B","usage":{"total_tokens":30483,"tool_uses":14,"duration_ms":31207},"last_tool_name":"run:B","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Bash","lastToolSummary":"sleep 13","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333360438,"tokens":10080,"toolCalls":4}],"uuid":"83773c7d-f250-49fc-9f1a-0ffe0d0d9e5a","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:B","usage":{"total_tokens":30483,"tool_uses":15,"duration_ms":31297},"last_tool_name":"run:B","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Read","lastToolSummary":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a66…","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333360532,"tokens":10080,"toolCalls":5}],"uuid":"5a848f1f-adc2-4afc-9a1f-74e3f14c49e6","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:A","usage":{"total_tokens":30678,"tool_uses":15,"duration_ms":31885},"last_tool_name":"run:A","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"sleep 8 && echo A_OK","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333361121,"tokens":10491,"toolCalls":6},{"type":"workflow_agent","index":1,"label":"run:A","phaseIndex":1,"phaseTitle":"Run","agentId":"a6a90a6f27c58e284","model":"global.anthropic.claude-opus-4-8","state":"done","startedAt":1781333329335,"queuedAt":1781333329316,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"sleep 8 && echo A_OK","promptPreview":"You are agent A in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 8 && echo A_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-ze…","lastProgressAt":1781333361128,"tokens":10491,"toolCalls":6,"durationMs":31793,"resultPreview":"{\"name\":\"A\",\"command\":\"sleep 8 && echo A_OK\",\"exitCode\":0,\"stdout\":\"A_OK\",\"succeeded\":true}"}],"uuid":"37cbd4cb-47fd-43f4-8628-71ec22b4982d","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Run: run:B","usage":{"total_tokens":30956,"tool_uses":16,"duration_ms":34652},"last_tool_name":"run:B","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"Read","lastToolSummary":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a66…","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333363887,"tokens":10358,"toolCalls":5},{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"sleep 12 && echo B_OK","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333363889,"tokens":10358,"toolCalls":6}],"uuid":"9bd926f0-18d2-4285-8ed6-6dd3a613774f","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Summarize: summarize:D","usage":{"total_tokens":31143,"tool_uses":16,"duration_ms":37874},"last_tool_name":"summarize:D","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"sleep 12 && echo B_OK","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333367105,"tokens":10545,"toolCalls":6},{"type":"workflow_agent","index":2,"label":"run:B","phaseIndex":1,"phaseTitle":"Run","agentId":"a4565ff12512d74a1","model":"global.anthropic.claude-opus-4-8","state":"done","startedAt":1781333329337,"queuedAt":1781333329316,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"sleep 12 && echo B_OK","promptPreview":"You are agent B in a parallelism demo.\nRun EXACTLY this shell command using the Bash tool with run_in_background: true so it executes detached and truly in parallel:\n\n sleep 12 && echo B_OK\n\nSteps:\n1. Call Bash with that exact command and run_in_background: true.\n2. Poll its output (TaskOutput / TaskGet) until the background process exits, capturing exit code and stdout.\n3. Do NOT treat a non-z…","lastProgressAt":1781333367107,"tokens":10545,"toolCalls":6,"durationMs":37770,"resultPreview":"{\"name\":\"B\",\"command\":\"sleep 12 && echo B_OK\",\"exitCode\":0,\"stdout\":\"B_OK\",\"succeeded\":true}"},{"type":"workflow_agent","index":4,"label":"summarize:D","phaseIndex":2,"phaseTitle":"Summarize","model":"global.anthropic.claude-opus-4-8","state":"start","queuedAt":1781333367108,"promptPreview":"You are agent D, the aggregator. Three sibling agents ran shell commands concurrently. Here are their structured results as JSON:\n\n[\n {\n \"name\": \"A\",\n \"command\": \"sleep 8 && echo A_OK\",\n \"exitCode\": 0,\n \"stdout\": \"A_OK\",\n \"succeeded\": true\n },\n {\n \"name\": \"B\",\n \"command\": \"sleep 12 && echo B_OK\",\n \"exitCode\": 0,\n \"stdout\": \"B_OK\",\n \"succeeded\": true\n },\n {\n \"n…","lastProgressAt":1781333367108},{"type":"workflow_agent","index":4,"label":"summarize:D","phaseIndex":2,"phaseTitle":"Summarize","agentId":"ad3d1081ece84bd42","model":"global.anthropic.claude-opus-4-8","state":"start","startedAt":1781333367109,"queuedAt":1781333367108,"attempt":1,"promptPreview":"You are agent D, the aggregator. Three sibling agents ran shell commands concurrently. Here are their structured results as JSON:\n\n[\n {\n \"name\": \"A\",\n \"command\": \"sleep 8 && echo A_OK\",\n \"exitCode\": 0,\n \"stdout\": \"A_OK\",\n \"succeeded\": true\n },\n {\n \"name\": \"B\",\n \"command\": \"sleep 12 && echo B_OK\",\n \"exitCode\": 0,\n \"stdout\": \"B_OK\",\n \"succeeded\": true\n },\n {\n \"n…","lastProgressAt":1781333367109}],"uuid":"e5cf7595-918d-45a1-b607-e5eaf06d44af","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Summarize: summarize:D","usage":{"total_tokens":39547,"tool_uses":16,"duration_ms":42546},"last_tool_name":"summarize:D","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":4,"label":"summarize:D","phaseIndex":2,"phaseTitle":"Summarize","agentId":"ad3d1081ece84bd42","model":"global.anthropic.claude-opus-4-8","state":"progress","startedAt":1781333367109,"queuedAt":1781333367108,"attempt":1,"promptPreview":"You are agent D, the aggregator. Three sibling agents ran shell commands concurrently. Here are their structured results as JSON:\n\n[\n {\n \"name\": \"A\",\n \"command\": \"sleep 8 && echo A_OK\",\n \"exitCode\": 0,\n \"stdout\": \"A_OK\",\n \"succeeded\": true\n },\n {\n \"name\": \"B\",\n \"command\": \"sleep 12 && echo B_OK\",\n \"exitCode\": 0,\n \"stdout\": \"B_OK\",\n \"succeeded\": true\n },\n {\n \"n…","lastProgressAt":1781333371773,"tokens":8404,"toolCalls":0}],"uuid":"7172f678-b6cf-4051-9c98-04bef8785e41","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_progress","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","description":"Summarize: summarize:D","usage":{"total_tokens":39547,"tool_uses":16,"duration_ms":42551},"last_tool_name":"summarize:D","summary":"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes","workflow_progress":[{"type":"workflow_agent","index":4,"label":"summarize:D","phaseIndex":2,"phaseTitle":"Summarize","agentId":"ad3d1081ece84bd42","model":"global.anthropic.claude-opus-4-8","state":"done","startedAt":1781333367109,"queuedAt":1781333367108,"attempt":1,"promptPreview":"You are agent D, the aggregator. Three sibling agents ran shell commands concurrently. Here are their structured results as JSON:\n\n[\n {\n \"name\": \"A\",\n \"command\": \"sleep 8 && echo A_OK\",\n \"exitCode\": 0,\n \"stdout\": \"A_OK\",\n \"succeeded\": true\n },\n {\n \"name\": \"B\",\n \"command\": \"sleep 12 && echo B_OK\",\n \"exitCode\": 0,\n \"stdout\": \"B_OK\",\n \"succeeded\": true\n },\n {\n \"n…","lastProgressAt":1781333371803,"tokens":8404,"toolCalls":0,"durationMs":4694,"resultPreview":"| Agent | Command | Exit Code | Stdout | Succeeded |\n|-------|------------------------|-----------|--------|-----------|\n| A | `sleep 8 && echo A_OK` | 0 | A_OK | Yes |\n| B | `sleep 12 && echo B_OK` | 0 | B_OK | Yes |\n| C | `exit 1` | 1 | (empty) | No (expected to fail with exit 1) |\n\nVerdict: 2/3 succeeded, C failed as intended."}],"uuid":"407db5b9-2d4b-4cbe-8fa0-b1d5883757df","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_updated","task_id":"wanv3yy20","patch":{"status":"completed","end_time":1781333371804},"uuid":"b89eb21e-0386-43b6-b294-4d2cb65c1379","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b"} +{"type":"system","subtype":"task_notification","task_id":"wanv3yy20","tool_use_id":"toolu_bdrk_01QMiFrMjbc6S5J2f9qkCSDa","status":"completed","output_file":"/private/tmp/claude-501/-private-tmp/8e57e34a-bc88-4d0c-a664-eac6c31d684b/tasks/wanv3yy20.output","summary":"Dynamic workflow \"Phase1: 3 parallel agents A/B/C run bash (one fails); Phase2: agent D summarizes\" completed","usage":{"total_tokens":39547,"tool_uses":16,"duration_ms":42550},"session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","uuid":"2ba7849a-8dde-4313-990a-66eb82254826"} +{"type":"system","subtype":"init","cwd":"/private/tmp","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","ListMcpResourcesTool","LSP","NotebookEdit","Read","ReadMcpResourceTool","ScheduleWakeup","SendMessage","Skill","TaskCreate","TaskGet","TaskList","TaskOutput","TaskStop","TaskUpdate","TeamCreate","TeamDelete","ToolSearch","WebFetch","Workflow","Write","mcp__argocd-dev__authenticate","mcp__argocd-dev__complete_authentication","mcp__atlassian__addCommentToJiraIssue","mcp__atlassian__addWorklogToJiraIssue","mcp__atlassian__atlassianUserInfo","mcp__atlassian__createConfluenceFooterComment","mcp__atlassian__createConfluenceInlineComment","mcp__atlassian__createConfluencePage","mcp__atlassian__createIssueLink","mcp__atlassian__createJiraIssue","mcp__atlassian__editJiraIssue","mcp__atlassian__fetch","mcp__atlassian__getAccessibleAtlassianResources","mcp__atlassian__getConfluenceCommentChildren","mcp__atlassian__getConfluencePage","mcp__atlassian__getConfluencePageDescendants","mcp__atlassian__getConfluencePageFooterComments","mcp__atlassian__getConfluencePageInlineComments","mcp__atlassian__getConfluenceSpaces","mcp__atlassian__getIssueLinkTypes","mcp__atlassian__getJiraIssue","mcp__atlassian__getJiraIssueRemoteIssueLinks","mcp__atlassian__getJiraIssueTypeMetaWithFields","mcp__atlassian__getJiraProjectIssueTypesMetadata","mcp__atlassian__getPagesInConfluenceSpace","mcp__atlassian__getTransitionsForJiraIssue","mcp__atlassian__getVisibleJiraProjects","mcp__atlassian__lookupJiraAccountId","mcp__atlassian__search","mcp__atlassian__searchConfluenceUsingCql","mcp__atlassian__searchJiraIssuesUsingJql","mcp__atlassian__transitionJiraIssue","mcp__atlassian__updateConfluencePage","mcp__plugin_aliyun-observability_aliyun-observability__cms_execute_promql_query","mcp__plugin_aliyun-observability_aliyun-observability__sls_describe_logstore","mcp__plugin_aliyun-observability_aliyun-observability__sls_diagnose_query","mcp__plugin_aliyun-observability_aliyun-observability__sls_execute_sql_query","mcp__plugin_aliyun-observability_aliyun-observability__sls_get_current_time","mcp__plugin_aliyun-observability_aliyun-observability__sls_get_regions","mcp__plugin_aliyun-observability_aliyun-observability__sls_list_logstores","mcp__plugin_aliyun-observability_aliyun-observability__sls_list_projects","mcp__plugin_superset_rc-superset__authenticate","mcp__plugin_superset_rc-superset__complete_authentication","mcp__plugin_superset_superset-v2__authenticate","mcp__plugin_superset_superset-v2__complete_authentication","mcp__stardust__create_pipeline","mcp__stardust__get_application","mcp__stardust__get_chart","mcp__stardust__get_cluster","mcp__stardust__get_execution","mcp__stardust__get_execution_logs","mcp__stardust__get_pipeline","mcp__stardust__get_template","mcp__stardust__get_template_version","mcp__stardust__list_applications","mcp__stardust__list_branches","mcp__stardust__list_charts","mcp__stardust__list_clusters","mcp__stardust__list_environments","mcp__stardust__list_executions","mcp__stardust__list_pipelines","mcp__stardust__list_tags","mcp__stardust__list_template_versions","mcp__stardust__list_templates","mcp__stardust__star_pipeline","mcp__stardust__trigger_build"],"mcp_servers":[{"name":"plugin:aliyun-observability:aliyun-observability","status":"connected"},{"name":"plugin:pionex-crm:pionex-crm","status":"failed"},{"name":"plugin:superset:rc-superset","status":"needs-auth"},{"name":"plugin:superset:superset-v2","status":"needs-auth"},{"name":"aliyun-observability","status":"failed"},{"name":"atlassian","status":"connected"},{"name":"stardust","status":"connected"},{"name":"argocd-dev","status":"needs-auth"}],"model":"global.anthropic.claude-opus-4-8","permissionMode":"bypassPermissions","slash_commands":["deep-research","polymarket-api:polymarket-api","superset:superset-guide","microservice-manifest-spec:manifest-init","microservice-manifest-spec:manifest-spec","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","loop","claude-api","run","run-skill-generator","clear","compact","context","heapdump","init","reload-skills","review","security-review","usage","insights","goal","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.177","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["deep-research","polymarket-api:polymarket-api","superset:superset-guide","microservice-manifest-spec:manifest-init","microservice-manifest-spec:manifest-spec","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","loop","claude-api","run","run-skill-generator"],"plugins":[{"name":"polymarket-api","path":"/Users/zhangruofan/.claude/plugins/cache/future-skills/polymarket-api/1.0.0","source":"polymarket-api@future-skills"},{"name":"aliyun-observability","path":"/Users/zhangruofan/.claude/plugins/cache/growthx-marketplace/aliyun-observability/0.1.0","source":"aliyun-observability@growthx-marketplace"},{"name":"pionex-crm","path":"/Users/zhangruofan/.claude/plugins/cache/growthx-marketplace/pionex-crm/0.1.0","source":"pionex-crm@growthx-marketplace"},{"name":"superset","path":"/Users/zhangruofan/.claude/plugins/cache/growthx-marketplace/superset/0.1.4","source":"superset@growthx-marketplace"},{"name":"gopls","path":"/Users/zhangruofan/.claude/plugins/cache/claude-code-lsps/gopls/0.1.0","source":"gopls@claude-code-lsps"},{"name":"microservice-manifest-spec","path":"/Users/zhangruofan/Skills/microservice-manifest-spec","source":"microservice-manifest-spec@local-skills"},{"name":"rust-analyzer-lsp","path":"/Users/zhangruofan/.claude/plugins/cache/claude-plugins-official/rust-analyzer-lsp/1.0.0","source":"rust-analyzer-lsp@claude-plugins-official"}],"analytics_disabled":true,"product_feedback_disabled":false,"uuid":"43607e2b-2c64-4dc6-b9e9-87a63b22e0fa","memory_paths":{"auto":"/Users/zhangruofan/.claude/projects/-private-tmp/memory/"},"fast_mode_state":"off"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_zx4bjk3vo3afzy47weaa77nk5qsv4rh2ctolcmklq6oieqlp6cta","type":"message","role":"assistant","content":[{"type":"text","text":"Workflow 完成 ✅ 4 个 agent、用时 ~42.5s。\n\n**Phase 1 — A/B/C 并行执行结果:**\n\n| Agent | 命令 | 退出码 | Stdout | 成功 |\n|-------|------|--------|--------|------|\n| A | `sleep 8 && echo A_OK` | 0 | `A_OK` | ✅ |\n| B | `sleep 12 && echo B_OK` | 0 | `B_OK` | ✅ |\n| C | `exit 1` | 1 | (空) | ❌ 故意失败 |\n\n**Phase 2 — agent D 汇总结论:**\n> **2/3 succeeded, C failed as intended.**(2 个成功,C 按预期失败)\n\n**几点验证:**\n- **真并行**生效 —— 总墙钟 ~42.5s 远小于串行下限(每个 agent 还含启动/轮询开销),A/B/C 是同时跑的,B 的 `sleep 12` 决定了 phase1 的下限。\n- **C 的失败被如实上报**,没有污染整批结果,也没让 `parallel` 漏掉它(`.then()` 兜底 + schema 强制 `succeeded:false`)。\n- **Phase2 正确等到三者齐全**后才汇总(barrier 行为符合预期)。\n\n需要的话我可以把它存成命名 workflow 方便复用,或者改造成 `pipeline`(C 一失败就立刻进汇总、不等 B)。"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1214,"cache_read_input_tokens":22080,"cache_creation":{"ephemeral_5m_input_tokens":1214,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","uuid":"bc96dd84-6d36-4015-8e8e-899efa1103d9"} +{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":37167,"duration_api_ms":73769,"ttft_ms":12345,"ttft_stream_ms":6385,"time_to_request_ms":48,"num_turns":2,"result":"✅ Workflow 已启动并在后台运行。\n\n**结构:**\n- **Phase 1 「Run」** — `parallel()` 同时派发 3 个 agent,互不阻塞:\n - **A**: `sleep 8 && echo A_OK` → 预期成功\n - **B**: `sleep 12 && echo B_OK` → 预期成功\n - **C**: `exit 1` → 故意失败\n - 每个 agent 被要求用 `Bash` + `run_in_background: true` 跑命令、轮询直到退出,并**如实**上报退出码(不把 C 的非零退出当成自身失败)。结构化 schema 强制返回 `{name, command, exitCode, stdout, succeeded}`。\n- **Phase 2 「Summarize」** — 等 3 个结果齐了(`parallel` 是 barrier),再派 agent **D** 汇总成表格 + 总体结论。\n\n**运行信息:**\n- Task ID: `wanv3yy20`\n- Run ID: `wf_a78f02af-e02`\n\n完成后我会收到通知并把 D 的汇总结果转给你。想实时看进度可以用 `/workflows`。\n\n> 说明:这里有两层\"并行\"——workflow 的 `parallel()` 让 A/B/C 三个 agent 并发(墙钟约等于最慢的 B≈12s,而非 8+12+0 串行);每个 agent 内部再用 `run_in_background` 让 bash 进程脱离前台真正后台执行。C 用 `.then()` 兜底,即使 agent 异常也不会让整批 `parallel` 拿到 null 而漏报。\n\n等待完成通知中…","stop_reason":"end_turn","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","total_cost_usd":0.5487815,"usage":{"input_tokens":3623,"cache_creation_input_tokens":22080,"cache_read_input_tokens":16759,"output_tokens":2235,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":22080},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{"global.anthropic.claude-opus-4-8":{"inputTokens":18680,"outputTokens":5500,"cacheReadInputTokens":171130,"cacheCreationInputTokens":66117,"webSearchRequests":0,"costUSD":0.72969625,"contextWindow":200000,"maxOutputTokens":64000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"f4c8253d-5825-4aa7-b8dc-bb9a81934da6"} +{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":10035,"duration_api_ms":118683,"ttft_ms":10007,"ttft_stream_ms":2653,"time_to_request_ms":14,"num_turns":1,"result":"Workflow 完成 ✅ 4 个 agent、用时 ~42.5s。\n\n**Phase 1 — A/B/C 并行执行结果:**\n\n| Agent | 命令 | 退出码 | Stdout | 成功 |\n|-------|------|--------|--------|------|\n| A | `sleep 8 && echo A_OK` | 0 | `A_OK` | ✅ |\n| B | `sleep 12 && echo B_OK` | 0 | `B_OK` | ✅ |\n| C | `exit 1` | 1 | (空) | ❌ 故意失败 |\n\n**Phase 2 — agent D 汇总结论:**\n> **2/3 succeeded, C failed as intended.**(2 个成功,C 按预期失败)\n\n**几点验证:**\n- **真并行**生效 —— 总墙钟 ~42.5s 远小于串行下限(每个 agent 还含启动/轮询开销),A/B/C 是同时跑的,B 的 `sleep 12` 决定了 phase1 的下限。\n- **C 的失败被如实上报**,没有污染整批结果,也没让 `parallel` 漏掉它(`.then()` 兜底 + schema 强制 `succeeded:false`)。\n- **Phase2 正确等到三者齐全**后才汇总(barrier 行为符合预期)。\n\n需要的话我可以把它存成命名 workflow 方便复用,或者改造成 `pipeline`(C 一失败就立刻进汇总、不等 B)。","stop_reason":"end_turn","session_id":"8e57e34a-bc88-4d0c-a664-eac6c31d684b","total_cost_usd":0.72969625,"usage":{"input_tokens":2,"cache_creation_input_tokens":1214,"cache_read_input_tokens":22080,"output_tokens":510,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":1214},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{"global.anthropic.claude-opus-4-8":{"inputTokens":18680,"outputTokens":5500,"cacheReadInputTokens":171130,"cacheCreationInputTokens":66117,"webSearchRequests":0,"costUSD":0.72969625,"contextWindow":200000,"maxOutputTokens":64000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","origin":{"kind":"task-notification"},"uuid":"2dd42255-8122-49b3-aeea-095ecc684f1d"} diff --git a/crates/aionui-session/tests/fixtures/claude_2.1.185_cancel_before_output_result.ndjson b/crates/aionui-session/tests/fixtures/claude_2.1.185_cancel_before_output_result.ndjson new file mode 100644 index 000000000..3d45204a5 --- /dev/null +++ b/crates/aionui-session/tests/fixtures/claude_2.1.185_cancel_before_output_result.ndjson @@ -0,0 +1 @@ +{"type":"result","is_error":true,"subtype":"error_during_execution","errors":["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null"]} diff --git a/crates/aionui-session/tests/fixtures/claude_error_result.ndjson b/crates/aionui-session/tests/fixtures/claude_error_result.ndjson new file mode 100644 index 000000000..afcd3cc50 --- /dev/null +++ b/crates/aionui-session/tests/fixtures/claude_error_result.ndjson @@ -0,0 +1,2 @@ +{"type":"system","subtype":"init","cwd":"/tmp","session_id":"00000000-0000-0000-0000-000000000001","tools":[],"mcp_servers":[],"model":"claude-opus-4-5","permissionMode":"bypassPermissions","apiKeySource":"none","claude_code_version":"2.1.0"} +{"type":"result","subtype":"error_during_execution","is_error":true,"session_id":"00000000-0000-0000-0000-000000000001","total_cost_usd":0.0} diff --git a/crates/aionui-session/tests/fixtures/claude_parallel_tools_turn.ndjson b/crates/aionui-session/tests/fixtures/claude_parallel_tools_turn.ndjson new file mode 100644 index 000000000..887f14fda --- /dev/null +++ b/crates/aionui-session/tests/fixtures/claude_parallel_tools_turn.ndjson @@ -0,0 +1,6 @@ +{"type":"system","subtype":"init","cwd":"/tmp","session_id":"00000000-0000-0000-0000-000000000001","tools":["Read"],"mcp_servers":[],"model":"claude-opus-4-5","permissionMode":"bypassPermissions","apiKeySource":"none","claude_code_version":"2.1.0"} +{"type":"assistant","message":{"id":"msg_par_1","model":"claude-opus-4-5","role":"assistant","content":[{"type":"tool_use","id":"toolu_par_A","name":"Read","input":{"file_path":"/tmp/a.txt"}},{"type":"tool_use","id":"toolu_par_B","name":"Read","input":{"file_path":"/tmp/b.txt"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":15,"output_tokens":12}}} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_par_B","content":"contents of B","is_error":false}]}} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_par_A","content":"contents of A","is_error":false}]}} +{"type":"assistant","message":{"id":"msg_par_2","model":"claude-opus-4-5","role":"assistant","content":[{"type":"text","text":"Read both files."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":20,"output_tokens":5}}} +{"type":"result","subtype":"success","is_error":false,"result":"Read both files.","stop_reason":"end_turn","session_id":"00000000-0000-0000-0000-000000000001","total_cost_usd":0.0} diff --git a/crates/aionui-session/tests/fixtures/claude_read_attachment_turn.ndjson b/crates/aionui-session/tests/fixtures/claude_read_attachment_turn.ndjson new file mode 100644 index 000000000..88eb82060 --- /dev/null +++ b/crates/aionui-session/tests/fixtures/claude_read_attachment_turn.ndjson @@ -0,0 +1,5 @@ +{"type":"system","subtype":"init","cwd":"/tmp","session_id":"00000000-0000-0000-0000-000000000001","tools":["Read"],"mcp_servers":[],"model":"claude-opus-4-5","permissionMode":"bypassPermissions","apiKeySource":"none","claude_code_version":"2.1.0"} +{"type":"assistant","message":{"id":"msg_a_001","model":"claude-opus-4-5","role":"assistant","content":[{"type":"tool_use","id":"toolu_read_att","name":"Read","input":{"file_path":"/tmp/attached.txt"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":8}}} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_read_att","content":"ATTACHMENT_MARKER_42","is_error":false}]}} +{"type":"assistant","message":{"id":"msg_a_002","model":"claude-opus-4-5","role":"assistant","content":[{"type":"text","text":"The file contains ATTACHMENT_MARKER_42."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":20,"output_tokens":6}}} +{"type":"result","subtype":"success","is_error":false,"result":"The file contains ATTACHMENT_MARKER_42.","stop_reason":"end_turn","session_id":"00000000-0000-0000-0000-000000000001","total_cost_usd":0.0} diff --git a/crates/aionui-session/tests/fixtures/claude_read_image_tool_turn.ndjson b/crates/aionui-session/tests/fixtures/claude_read_image_tool_turn.ndjson new file mode 100644 index 000000000..ff4c398b4 --- /dev/null +++ b/crates/aionui-session/tests/fixtures/claude_read_image_tool_turn.ndjson @@ -0,0 +1,5 @@ +{"type":"system","subtype":"init","cwd":"/tmp","session_id":"00000000-0000-0000-0000-000000000001","tools":["Read"],"mcp_servers":[],"model":"claude-opus-4-5","permissionMode":"bypassPermissions","apiKeySource":"none","claude_code_version":"2.1.0"} +{"type":"assistant","message":{"id":"msg_r_001","model":"claude-opus-4-5","role":"assistant","content":[{"type":"tool_use","id":"toolu_read_1","name":"Read","input":{"file_path":"/tmp/pic.png"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":8}}} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_read_1","content":[{"type":"text","text":"Here is the image:"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="}}],"is_error":false}]}} +{"type":"assistant","message":{"id":"msg_r_002","model":"claude-opus-4-5","role":"assistant","content":[{"type":"text","text":"I see a green pixel."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":20,"output_tokens":6}}} +{"type":"result","subtype":"success","is_error":false,"result":"I see a green pixel.","stop_reason":"end_turn","session_id":"00000000-0000-0000-0000-000000000001","total_cost_usd":0.0} diff --git a/crates/aionui-session/tests/fixtures/claude_simple_text_turn.ndjson b/crates/aionui-session/tests/fixtures/claude_simple_text_turn.ndjson new file mode 100644 index 000000000..27e564a32 --- /dev/null +++ b/crates/aionui-session/tests/fixtures/claude_simple_text_turn.ndjson @@ -0,0 +1,3 @@ +{"type":"system","subtype":"init","cwd":"/tmp","session_id":"00000000-0000-0000-0000-000000000001","tools":[],"mcp_servers":[],"model":"claude-opus-4-5","permissionMode":"bypassPermissions","apiKeySource":"none","claude_code_version":"2.1.0"} +{"type":"assistant","message":{"id":"msg_test_001","model":"claude-opus-4-5","role":"assistant","content":[{"type":"text","text":"Hello from fake backend"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":5}}} +{"type":"result","subtype":"success","is_error":false,"result":"Hello from fake backend","stop_reason":"end_turn","session_id":"00000000-0000-0000-0000-000000000001","total_cost_usd":0.0} diff --git a/crates/aionui-session/tests/fixtures/claude_thinking_then_text_turn.ndjson b/crates/aionui-session/tests/fixtures/claude_thinking_then_text_turn.ndjson new file mode 100644 index 000000000..1d3f157d8 --- /dev/null +++ b/crates/aionui-session/tests/fixtures/claude_thinking_then_text_turn.ndjson @@ -0,0 +1,4 @@ +{"type":"system","subtype":"init","cwd":"/tmp","session_id":"00000000-0000-0000-0000-000000000001","tools":[],"mcp_servers":[],"model":"claude-opus-4-5","permissionMode":"bypassPermissions","apiKeySource":"none","claude_code_version":"2.1.0"} +{"type":"assistant","message":{"id":"msg_think_1","model":"claude-opus-4-5","role":"assistant","content":[{"type":"thinking","thinking":"Let me reason about this step by step."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":8}}} +{"type":"assistant","message":{"id":"msg_text_1","model":"claude-opus-4-5","role":"assistant","content":[{"type":"text","text":"The answer is 42."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":6}}} +{"type":"result","subtype":"success","is_error":false,"result":"The answer is 42.","stop_reason":"end_turn","session_id":"00000000-0000-0000-0000-000000000001","total_cost_usd":0.0} diff --git a/crates/aionui-session/tests/fixtures/claude_write_tool_turn.ndjson b/crates/aionui-session/tests/fixtures/claude_write_tool_turn.ndjson new file mode 100644 index 000000000..6a4a2ddfc --- /dev/null +++ b/crates/aionui-session/tests/fixtures/claude_write_tool_turn.ndjson @@ -0,0 +1,5 @@ +{"type":"system","subtype":"init","cwd":"/tmp","session_id":"00000000-0000-0000-0000-000000000001","tools":["Write"],"mcp_servers":[],"model":"claude-opus-4-5","permissionMode":"bypassPermissions","apiKeySource":"none","claude_code_version":"2.1.0"} +{"type":"assistant","message":{"id":"msg_w_001","model":"claude-opus-4-5","role":"assistant","content":[{"type":"tool_use","id":"toolu_write_1","name":"Write","input":{"file_path":"/tmp/hello.txt","content":"hello world"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":8}}} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_write_1","content":"File created successfully at /tmp/hello.txt","is_error":false}]}} +{"type":"assistant","message":{"id":"msg_w_002","model":"claude-opus-4-5","role":"assistant","content":[{"type":"text","text":"Done — wrote hello.txt."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":20,"output_tokens":6}}} +{"type":"result","subtype":"success","is_error":false,"result":"Done — wrote hello.txt.","stop_reason":"end_turn","session_id":"00000000-0000-0000-0000-000000000001","total_cost_usd":0.0} diff --git a/crates/aionui-session/tests/fixtures/trace-tool.ndjson b/crates/aionui-session/tests/fixtures/trace-tool.ndjson new file mode 100644 index 000000000..dc38d113a --- /dev/null +++ b/crates/aionui-session/tests/fixtures/trace-tool.ndjson @@ -0,0 +1,6 @@ +{"type":"system","subtype":"init","cwd":"/private/tmp/cctest","session_id":"1cd00b61-809b-484c-beef-fc7d20857f86","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","ListMcpResourcesTool","LSP","NotebookEdit","Read","ReadMcpResourceTool","ScheduleWakeup","SendMessage","Skill","TaskCreate","TaskGet","TaskList","TaskOutput","TaskStop","TaskUpdate","TeamCreate","TeamDelete","ToolSearch","WebFetch","Workflow","Write","mcp__argocd-dev__authenticate","mcp__argocd-dev__complete_authentication","mcp__atlassian__addCommentToJiraIssue","mcp__atlassian__addWorklogToJiraIssue","mcp__atlassian__atlassianUserInfo","mcp__atlassian__createConfluenceFooterComment","mcp__atlassian__createConfluenceInlineComment","mcp__atlassian__createConfluencePage","mcp__atlassian__createIssueLink","mcp__atlassian__createJiraIssue","mcp__atlassian__editJiraIssue","mcp__atlassian__fetch","mcp__atlassian__getAccessibleAtlassianResources","mcp__atlassian__getConfluenceCommentChildren","mcp__atlassian__getConfluencePage","mcp__atlassian__getConfluencePageDescendants","mcp__atlassian__getConfluencePageFooterComments","mcp__atlassian__getConfluencePageInlineComments","mcp__atlassian__getConfluenceSpaces","mcp__atlassian__getIssueLinkTypes","mcp__atlassian__getJiraIssue","mcp__atlassian__getJiraIssueRemoteIssueLinks","mcp__atlassian__getJiraIssueTypeMetaWithFields","mcp__atlassian__getJiraProjectIssueTypesMetadata","mcp__atlassian__getPagesInConfluenceSpace","mcp__atlassian__getTransitionsForJiraIssue","mcp__atlassian__getVisibleJiraProjects","mcp__atlassian__lookupJiraAccountId","mcp__atlassian__search","mcp__atlassian__searchConfluenceUsingCql","mcp__atlassian__searchJiraIssuesUsingJql","mcp__atlassian__transitionJiraIssue","mcp__atlassian__updateConfluencePage","mcp__plugin_aliyun-observability_aliyun-observability__cms_execute_promql_query","mcp__plugin_aliyun-observability_aliyun-observability__sls_describe_logstore","mcp__plugin_aliyun-observability_aliyun-observability__sls_diagnose_query","mcp__plugin_aliyun-observability_aliyun-observability__sls_execute_sql_query","mcp__plugin_aliyun-observability_aliyun-observability__sls_get_current_time","mcp__plugin_aliyun-observability_aliyun-observability__sls_get_regions","mcp__plugin_aliyun-observability_aliyun-observability__sls_list_logstores","mcp__plugin_aliyun-observability_aliyun-observability__sls_list_projects","mcp__plugin_superset_rc-superset__authenticate","mcp__plugin_superset_rc-superset__complete_authentication","mcp__plugin_superset_superset-v2__authenticate","mcp__plugin_superset_superset-v2__complete_authentication","mcp__stardust__create_pipeline","mcp__stardust__get_application","mcp__stardust__get_chart","mcp__stardust__get_cluster","mcp__stardust__get_execution","mcp__stardust__get_execution_logs","mcp__stardust__get_pipeline","mcp__stardust__get_template","mcp__stardust__get_template_version","mcp__stardust__list_applications","mcp__stardust__list_branches","mcp__stardust__list_charts","mcp__stardust__list_clusters","mcp__stardust__list_environments","mcp__stardust__list_executions","mcp__stardust__list_pipelines","mcp__stardust__list_tags","mcp__stardust__list_template_versions","mcp__stardust__list_templates","mcp__stardust__star_pipeline","mcp__stardust__trigger_build"],"mcp_servers":[{"name":"plugin:aliyun-observability:aliyun-observability","status":"connected"},{"name":"plugin:pionex-crm:pionex-crm","status":"failed"},{"name":"plugin:superset:rc-superset","status":"needs-auth"},{"name":"plugin:superset:superset-v2","status":"needs-auth"},{"name":"aliyun-observability","status":"failed"},{"name":"atlassian","status":"connected"},{"name":"stardust","status":"connected"},{"name":"argocd-dev","status":"needs-auth"}],"model":"global.anthropic.claude-opus-4-8","permissionMode":"bypassPermissions","slash_commands":["deep-research","polymarket-api:polymarket-api","superset:superset-guide","microservice-manifest-spec:manifest-init","microservice-manifest-spec:manifest-spec","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","loop","claude-api","run","run-skill-generator","clear","compact","context","heapdump","init","reload-skills","review","security-review","usage","insights","goal","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.162","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["deep-research","polymarket-api:polymarket-api","superset:superset-guide","microservice-manifest-spec:manifest-init","microservice-manifest-spec:manifest-spec","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","loop","claude-api","run","run-skill-generator"],"plugins":[{"name":"polymarket-api","path":"/Users/zhangruofan/.claude/plugins/cache/future-skills/polymarket-api/1.0.0","source":"polymarket-api@future-skills"},{"name":"aliyun-observability","path":"/Users/zhangruofan/.claude/plugins/cache/growthx-marketplace/aliyun-observability/0.1.0","source":"aliyun-observability@growthx-marketplace"},{"name":"pionex-crm","path":"/Users/zhangruofan/.claude/plugins/cache/growthx-marketplace/pionex-crm/0.1.0","source":"pionex-crm@growthx-marketplace"},{"name":"superset","path":"/Users/zhangruofan/.claude/plugins/cache/growthx-marketplace/superset/0.1.4","source":"superset@growthx-marketplace"},{"name":"gopls","path":"/Users/zhangruofan/.claude/plugins/cache/claude-code-lsps/gopls/0.1.0","source":"gopls@claude-code-lsps"},{"name":"microservice-manifest-spec","path":"/Users/zhangruofan/Skills/microservice-manifest-spec","source":"microservice-manifest-spec@local-skills"},{"name":"rust-analyzer-lsp","path":"/Users/zhangruofan/.claude/plugins/cache/claude-plugins-official/rust-analyzer-lsp/1.0.0","source":"rust-analyzer-lsp@claude-plugins-official"}],"analytics_disabled":true,"product_feedback_disabled":false,"uuid":"0c3171b8-845b-49a6-a24d-8d8ca9a592a1","memory_paths":{"auto":"/Users/zhangruofan/.claude/projects/-private-tmp-cctest/memory/"},"fast_mode_state":"off"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_67pycrhvx7sq7yrzaik5lxid5wlmbzllp73sbqnvrh67wbxpp2fa","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"EvECCmMIDhABGAIqQB3btp5BpgHBMT24JApVacgqHeamL3xwxHZ5NNQqg4dfm2Qq+l2bweEPg8USvduKVKQJpAWm9nHT/2YhVZpLmKoyDWNsYXVkZS1xdWluY2U4AEIIdGhpbmtpbmcSDOhmzDzHT9oOW9PTlRoM2Ss/O1OUQYNo1Mn9IjDa4n5ndVZp0aSgF7FAEIqyJRYT3pXZU8lzSNrfR80Q2aWZX7FSLDHZOrDvnCh7CC0quwE7J33dYaPyxkrj/I2IMrQgqaQAQ9hxThAC3fzdbt6NHKPb8OqbouNKau6MaWt/79Tr6vpQME9aWGhDmSH+tJjwRFOY5QlGKiSpgql/UKnpGpc5Ry9i58B/ui8hi4mWBR9801i5Q0EopyR1vlzpKQTftxNbnEYcP277ZxCSkbvVFHCz/Z3xXKCmshuKEg/MYVWa92S4GRPeKJbNuMxrqyMt1yrCjkGAyRubsR4W2cvXzTyHZGtkpVSCoP5DGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3470,"cache_creation_input_tokens":2253,"cache_read_input_tokens":14127,"cache_creation":{"ephemeral_5m_input_tokens":2253,"ephemeral_1h_input_tokens":0},"output_tokens":30,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"1cd00b61-809b-484c-beef-fc7d20857f86","uuid":"aafcf4eb-4ae3-485c-bf89-9e8a46d71dc8"} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_67pycrhvx7sq7yrzaik5lxid5wlmbzllp73sbqnvrh67wbxpp2fa","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_bdrk_0184MJrAT3hNJRqqmFCTJafr","name":"Write","input":{"file_path":"/private/tmp/cctest/hello.txt","content":"world\n"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3470,"cache_creation_input_tokens":2253,"cache_read_input_tokens":14127,"cache_creation":{"ephemeral_5m_input_tokens":2253,"ephemeral_1h_input_tokens":0},"output_tokens":30,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"1cd00b61-809b-484c-beef-fc7d20857f86","uuid":"2a6d6cc1-890e-446a-a383-f9ca05b9cd80"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_bdrk_0184MJrAT3hNJRqqmFCTJafr","type":"tool_result","content":"File created successfully at: /private/tmp/cctest/hello.txt (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"1cd00b61-809b-484c-beef-fc7d20857f86","uuid":"393fa21d-c6d8-4132-a05d-bcd55f64cecf","timestamp":"2026-06-04T13:51:16.315Z","tool_use_result":{"type":"create","filePath":"/private/tmp/cctest/hello.txt","content":"world\n","structuredPatch":[],"originalFile":null,"userModified":false}} +{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_bdrk_6xsnq7tqxn55zige5ljmj5ey76d7dd2f3l5bbsgo2gm4gdxye4ra","type":"message","role":"assistant","content":[{"type":"text","text":"Created `/private/tmp/cctest/hello.txt` containing `world`."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3598,"cache_read_input_tokens":16380,"cache_creation":{"ephemeral_5m_input_tokens":3598,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard"},"context_management":null},"parent_tool_use_id":null,"session_id":"1cd00b61-809b-484c-beef-fc7d20857f86","uuid":"a43c1bee-c3ea-4835-ac05-429f0132ef7a"} +{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":9206,"duration_api_ms":11625,"ttft_ms":6408,"time_to_request_ms":21,"num_turns":2,"result":"Created `/private/tmp/cctest/hello.txt` containing `world`.","stop_reason":"end_turn","session_id":"1cd00b61-809b-484c-beef-fc7d20857f86","total_cost_usd":0.07386925,"usage":{"input_tokens":3472,"cache_creation_input_tokens":5851,"cache_read_input_tokens":30507,"output_tokens":171,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":5851},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{"global.anthropic.claude-haiku-4-5-20251001-v1:0":{"inputTokens":312,"outputTokens":20,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.000412,"contextWindow":200000,"maxOutputTokens":32000},"global.anthropic.claude-opus-4-8":{"inputTokens":3472,"outputTokens":171,"cacheReadInputTokens":30507,"cacheCreationInputTokens":5851,"webSearchRequests":0,"costUSD":0.07345725,"contextWindow":200000,"maxOutputTokens":64000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"97566736-19a7-4458-8a39-d3990997ca0b"}