From 42c0f51277161f83839a96564ec5a7e24bc95aa7 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Mon, 6 Jul 2026 17:55:23 +0800 Subject: [PATCH 01/22] feat(session-port): route claude/codex through SessionAgentTask (squashed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed port of the clean-slate session model onto the origin/main baseline: claude/codex backends run through SessionAgentTask (IAgentTask adapter over SessionBackend), with event-pump, resume/self-heal, permission recovery, usage/mode/model handling, and codex tool-output + plan streaming. Standalone test files are kept out of tree by intent. Rolled-up commits: - chore(session-port): land aionui-session + aionui-process on origin baseline (phase 0) - feat(session-port): SessionAgentTask adapter (SessionBackend → IAgentTask) [phase 1a] - feat(session-port): add AgentInstance::Session variant + enum-level arms [phase 1b-1] - feat(session-port): route claude/codex through SessionAgentTask in the factory [phase 1b-2] - session-port: close build_session_instance enrichment gaps (resume/mode/mcp/preset/env/sandbox/catalog) - session-port: wire event-pump gaps the ACP path had (persist anchor/config, permission, usage) - session-port: port clean-slate's session-mapping tests + cover pump persistence - session-port: align Start/Finish frames with the ACP path (live A/B verified) - session-port: fix usage payload shape + suppress opening config frame (A/B) - session-port: render AskUserQuestion as its own options, not a generic allow/deny card - session-port: recover pending permissions via get_confirmations (was empty → turn hang) - session-port: stop mode/model switch from lighting a spurious timer bar - session-port: forward codex Plan to the frontend (was dropped) - session-port: fix mode/model switch command_ack error + suppress cancel error noise - session-port: stream codex tool output (ToolOutputDelta) live to the frontend - session-port: terminate turn errors as the rich AgentStreamEvent::Error, not a plain Tips - session-port: normalize mode alias on resume (was raw → wrong/rejected mode) - session(claude): disable AskUserQuestion until frontend has a multi-question renderer - fix(db): bump conversations.updated_at on message persist so recent activity floats to top - session-port: suppress non-blocking Workflow launch result via SegmentBreak - session-port: reap child CLI on task drop + self-heal dead resume anchor - fix(session): preserve tool name across a call's follow-up frames --- AGENTS.md | 16 + Cargo.lock | 111 + Cargo.toml | 5 + crates/aionui-ai-agent/Cargo.toml | 2 + crates/aionui-ai-agent/src/agent_task.rs | 29 +- crates/aionui-ai-agent/src/factory/acp.rs | 42 + crates/aionui-ai-agent/src/factory/mod.rs | 5 + crates/aionui-ai-agent/src/lib.rs | 6 + crates/aionui-ai-agent/src/mcp_resolve.rs | 175 + .../src/persistence/acp_session_sync.rs | 8 + .../src/protocol/events/mod.rs | 11 + crates/aionui-ai-agent/src/session_agent.rs | 2984 +++++++++ .../tests/acp_agent_integration.rs | 1 + .../tests/factory_provider_integration.rs | 1 + crates/aionui-app/Cargo.toml | 3 + crates/aionui-app/src/services.rs | 29 + crates/aionui-channel/src/message_service.rs | 3 +- crates/aionui-common/src/error_extract.rs | 253 + crates/aionui-common/src/lib.rs | 1 + .../aionui-conversation/src/service_test.rs | 4 + .../aionui-conversation/src/stream_relay.rs | 76 + crates/aionui-cron/src/executor.rs | 3 + .../aionui-db/src/repository/acp_session.rs | 10 + .../src/repository/sqlite_acp_session.rs | 32 + .../src/repository/sqlite_conversation.rs | 77 +- crates/aionui-process/Cargo.toml | 41 + crates/aionui-process/src/capabilities.rs | 136 + crates/aionui-process/src/containment.rs | 69 + crates/aionui-process/src/error.rs | 38 + crates/aionui-process/src/instance_lock.rs | 86 + crates/aionui-process/src/lib.rs | 42 + crates/aionui-process/src/proc_control.rs | 467 ++ crates/aionui-process/src/process.rs | 631 ++ crates/aionui-process/src/registry_store.rs | 378 ++ crates/aionui-process/src/spawner.rs | 159 + crates/aionui-process/src/supervisor/core.rs | 124 + crates/aionui-process/src/supervisor/mod.rs | 143 + crates/aionui-session/Cargo.toml | 43 + .../proptest-regressions/adapter/claude.txt | 8 + crates/aionui-session/src/adapter.rs | 228 + crates/aionui-session/src/adapter/claude.rs | 2166 ++++++ crates/aionui-session/src/backend/acp_conn.rs | 4031 ++++++++++++ .../aionui-session/src/backend/claude_conn.rs | 4961 ++++++++++++++ .../aionui-session/src/backend/codex_conn.rs | 5820 +++++++++++++++++ .../src/backend/conversation_session.rs | 1547 +++++ crates/aionui-session/src/backend/mod.rs | 232 + .../src/backend/orchestrator.rs | 2915 +++++++++ .../aionui-session/src/backend/rehydrate.rs | 61 + crates/aionui-session/src/backend/suspend.rs | 408 ++ crates/aionui-session/src/backend/types.rs | 404 ++ crates/aionui-session/src/capability.rs | 201 + crates/aionui-session/src/error.rs | 22 + crates/aionui-session/src/event.rs | 1599 +++++ crates/aionui-session/src/lib.rs | 79 + crates/aionui-session/src/reducer.rs | 2270 +++++++ crates/aionui-session/src/state.rs | 640 ++ crates/aionui-session/src/testing.rs | 678 ++ 57 files changed, 34510 insertions(+), 4 deletions(-) create mode 100644 crates/aionui-ai-agent/src/mcp_resolve.rs create mode 100644 crates/aionui-ai-agent/src/session_agent.rs create mode 100644 crates/aionui-common/src/error_extract.rs create mode 100644 crates/aionui-process/Cargo.toml create mode 100644 crates/aionui-process/src/capabilities.rs create mode 100644 crates/aionui-process/src/containment.rs create mode 100644 crates/aionui-process/src/error.rs create mode 100644 crates/aionui-process/src/instance_lock.rs create mode 100644 crates/aionui-process/src/lib.rs create mode 100644 crates/aionui-process/src/proc_control.rs create mode 100644 crates/aionui-process/src/process.rs create mode 100644 crates/aionui-process/src/registry_store.rs create mode 100644 crates/aionui-process/src/spawner.rs create mode 100644 crates/aionui-process/src/supervisor/core.rs create mode 100644 crates/aionui-process/src/supervisor/mod.rs create mode 100644 crates/aionui-session/Cargo.toml create mode 100644 crates/aionui-session/proptest-regressions/adapter/claude.txt create mode 100644 crates/aionui-session/src/adapter.rs create mode 100644 crates/aionui-session/src/adapter/claude.rs create mode 100644 crates/aionui-session/src/backend/acp_conn.rs create mode 100644 crates/aionui-session/src/backend/claude_conn.rs create mode 100644 crates/aionui-session/src/backend/codex_conn.rs create mode 100644 crates/aionui-session/src/backend/conversation_session.rs create mode 100644 crates/aionui-session/src/backend/mod.rs create mode 100644 crates/aionui-session/src/backend/orchestrator.rs create mode 100644 crates/aionui-session/src/backend/rehydrate.rs create mode 100644 crates/aionui-session/src/backend/suspend.rs create mode 100644 crates/aionui-session/src/backend/types.rs create mode 100644 crates/aionui-session/src/capability.rs create mode 100644 crates/aionui-session/src/error.rs create mode 100644 crates/aionui-session/src/event.rs create mode 100644 crates/aionui-session/src/lib.rs create mode 100644 crates/aionui-session/src/reducer.rs create mode 100644 crates/aionui-session/src/state.rs create mode 100644 crates/aionui-session/src/testing.rs diff --git a/AGENTS.md b/AGENTS.md index fe2fc1d3d..e51e8bebe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,22 @@ Project-specific rules and conventions for AI assistants and contributors. ## High-Priority Rules +### 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 af94e91b2..8c3f3ca6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -346,8 +346,10 @@ dependencies = [ "aionui-db", "aionui-extension", "aionui-mcp", + "aionui-process", "aionui-realtime", "aionui-runtime", + "aionui-session", "aionui-system", "aionui-team-prompts", "async-trait", @@ -404,6 +406,7 @@ dependencies = [ "aionui-file", "aionui-mcp", "aionui-office", + "aionui-process", "aionui-realtime", "aionui-runtime", "aionui-shell", @@ -436,6 +439,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "uuid", "windows-sys 0.61.2", "wiremock", ] @@ -734,6 +738,25 @@ dependencies = [ "which 7.0.3", ] +[[package]] +name = "aionui-process" +version = "0.1.40" +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.42" @@ -776,6 +799,26 @@ dependencies = [ "zstd", ] +[[package]] +name = "aionui-session" +version = "0.1.40" +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.42" @@ -1487,6 +1530,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" @@ -3979,6 +4037,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" @@ -4002,6 +4079,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.31.0" @@ -4147,6 +4230,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" @@ -4495,6 +4587,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" @@ -5681,6 +5785,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" @@ -5799,6 +5909,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 771ef83df..93864327b 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 b093112c8..e41a87594 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 475d63984..aa4603d21 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 d949c8538..124eafe08 100644 --- a/crates/aionui-ai-agent/src/lib.rs +++ b/crates/aionui-ai-agent/src/lib.rs @@ -11,12 +11,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/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs new file mode 100644 index 000000000..c17176186 --- /dev/null +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -0,0 +1,2984 @@ +//! `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::ThinkingEventData; +use crate::protocol::events::tool_call::{ToolCallEventData, ToolCallStatus}; +use crate::protocol::events::{AgentStreamEvent, FinishEventData, StartEventData, TextEventData}; +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"; + +/// 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>, +} + +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()) + } +} + +/// One claude/codex session, presented as an `IAgentTask`. +pub struct SessionAgentTask { + agent_type: AgentType, + conversation_id: String, + workspace: String, + backend: Arc, + runtime: Arc, + /// 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 { + 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), + }); + // 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); + Arc::new(Self { + agent_type, + conversation_id, + workspace, + backend, + runtime, + 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(()) + } + + /// 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(); + Ok(aionui_api_types::AgentModeResponse { + mode: self.runtime.mode_override().or(caps.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 { + let caps = self.backend.capabilities(); + let override_model = self.runtime.model_override(); + if caps.available_models.is_empty() && caps.current_model.is_none() && override_model.is_none() { + return Ok(aionui_api_types::GetModelInfoResponse { model_info: None }); + } + let available_models: Vec = caps + .available_models + .iter() + .map(|m| aionui_api_types::ModelInfoEntry { + id: m.id.clone(), + label: m.name.clone(), + }) + .collect(); + let current_id = override_model.or_else(|| caps.current_model.clone()); + 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 { + let caps = self.backend.capabilities(); + let mut config_options = Vec::new(); + if !caps.available_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_else(|| caps.current_mode.clone()), + options: caps + .available_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 !caps.available_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_else(|| caps.current_model.clone()), + options: caps + .available_models + .iter() + .map(|m| aionui_api_types::AcpConfigSelectOptionDto { + value: m.id.clone(), + name: Some(m.name.clone()), + label: None, + description: m.description.clone(), + }) + .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 { + 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. + // Non-mode/model options (effort/thought_level) have no picker current_value + // to confirm — they report CommandAck (accepted, applied live). + match option_id { + "mode" => self.runtime.set_mode_override(value.to_string()), + "model" => self.runtime.set_model_override(value.to_string()), + _ => { + return Ok(aionui_api_types::SetConfigOptionResponse { + confirmation: aionui_api_types::ConfigOptionConfirmation::CommandAck, + config_options: None, + }); + } + } + let snapshot = self.get_config_options().await?; + let observed = snapshot + .config_options + .iter() + .find(|o| 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), + }) + } + + /// 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()); + } + } + + let backend = connection + .open_session(spec, session_config) + .await + .map_err(|e| AgentError::bad_gateway(format!("open {backend_label} session: {e}")))?; + + // 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(AgentType::Acp, conversation_id, workspace, backend, acp_session_repo); + 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() + }) +} + +/// legacy `codex_sandbox::sandbox_mode_for_requested_mode`: only an explicit +/// full-access / yolo mode escalates to `danger-full-access`; everything else +/// (including `None`) stays at the safe default (returned here as `None` so the +/// backend's `unwrap_or("workspace-write")` applies). Verbatim port of clean-slate +/// `session_runtime::codex_sandbox_for_mode`. +fn codex_sandbox_for_mode(mode: Option<&str>) -> Option<&'static str> { + match mode.map(str::trim) { + Some("full-access" | "yoloNoSandbox") => Some("danger-full-access"), + _ => 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"`. 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) { + Some("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(); + 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"); + + // 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 { + 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; + } + + // 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), + SessionEvent::TurnResult { .. } | SessionEvent::Detached { .. } if !suppress_intermediate_finish => { + runtime.set_status(ConversationStatus::Finished); + // 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(); + } + // 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) { + // 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); + // 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`. +fn translate_event(event: SessionEvent, _conversation_id: &str) -> 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 { .. } => { + 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(), + // 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, + }, + )] + } + // 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 / Notice / 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" + ); + } + + /// Only an explicit full-access / yolo mode escalates the codex sandbox; every + /// other mode (and None) keeps None ⇒ workspace-write. Ported verbatim. + #[test] + fn codex_sandbox_maps_only_full_access_modes() { + assert_eq!(codex_sandbox_for_mode(Some("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(" full-access ")), + Some("danger-full-access") + ); + 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("full-access")), Some("never")); + assert_eq!(codex_approval_for_mode(Some("yoloNoSandbox")), Some("never")); + assert_eq!(codex_approval_for_mode(Some(" full-access ")), Some("never")); + 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", + ); + 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", + ); + 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", + ); + 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", + ); + 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 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", + ); + 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", + ); + 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", + ); + 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", + ); + 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(_)))); + } +} + +#[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")); + } + + // ── 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, + } + } + + // Collect every frame the task forwards until its event stream drains. + async fn drain(task: &Arc) -> Vec { + let mut rx = crate::agent_task::IAgentTask::subscribe(task.as_ref()); + 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). + loop { + match tokio::time::timeout(std::time::Duration::from_millis(300), rx.recv()).await { + Ok(Ok(ev)) => out.push(ev), + _ => break, + } + } + 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 backend: Arc = Arc::new(ScriptBackend(script)); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let frames = drain(&task).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" + ); + } + + // 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 backend: Arc = Arc::new(ScriptBackend(script)); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let frames = drain(&task).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 backend: Arc = Arc::new(ScriptBackend(script)); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let frames = drain(&task).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. + 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")); + } + + // 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 backend: Arc = Arc::new(ScriptBackend(script)); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let frames = drain(&task).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"); + } +} 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 67f8f95a5..ec6ef2232 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 89fe1df71..5536e2415 100644 --- a/crates/aionui-app/Cargo.toml +++ b/crates/aionui-app/Cargo.toml @@ -35,10 +35,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 c05cdbb72..8ed5a8af4 100644 --- a/crates/aionui-app/src/services.rs +++ b/crates/aionui-app/src/services.rs @@ -163,6 +163,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, @@ -174,6 +202,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 630617a70..75a33d3e7 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_relay.rs b/crates/aionui-conversation/src/stream_relay.rs index 770ded539..e4efae69e 100644 --- a/crates/aionui-conversation/src/stream_relay.rs +++ b/crates/aionui-conversation/src/stream_relay.rs @@ -282,6 +282,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; @@ -563,6 +572,7 @@ impl StreamRelay { AgentStreamEvent::System(_) => "System", AgentStreamEvent::RequestTrace(_) => "RequestTrace", AgentStreamEvent::SessionAssigned(_) => "SessionAssigned", + AgentStreamEvent::SegmentBreak => "SegmentBreak", } } @@ -929,6 +939,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-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index f4b7c68c8..04a275b10 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -3150,6 +3150,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..fb8b05cc9 --- /dev/null +++ b/crates/aionui-session/src/adapter.rs @@ -0,0 +1,228 @@ +//! 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 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..f658917d5 --- /dev/null +++ b/crates/aionui-session/src/adapter/claude.rs @@ -0,0 +1,2166 @@ +//! `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 both the advertised mode picker +/// ([`claude_permission_modes`]) and 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. +const CLAUDE_PERMISSION_MODE_IDS: [&str; 4] = ["default", "plan", "acceptEdits", "bypassPermissions"]; + +/// 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 FIXED permission-mode enum, 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; only name/description are display copy. Unlike +/// models/effort these are static (no `initialize`-response discovery). +pub(crate) fn claude_permission_modes() -> Vec { + [ + ("default", "Default", "Standard permission prompts"), + ("plan", "Plan", "Plan first, no edits until approved"), + ("acceptEdits", "Accept Edits", "Auto-accept file edits"), + ("bypassPermissions", "Bypass Permissions", "Skip all permission prompts"), + ] + .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..cc29e0956 --- /dev/null +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -0,0 +1,4961 @@ +//! 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`/`plan`/`acceptEdits`/`bypassPermissions` are claude's exact + // wire values (see `claude_permission_modes`). + let mode = config + .mode + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .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). + 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); + // 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; + 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). + stdout.read(&mut chunk).await.map_err(|_| ()) + } 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). On a real + // EOF/exit we 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 { + 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>) { + 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 mut caps = discovered_caps.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(models) = models { + caps.models = models + .iter() + .filter_map(|m| { + let id = m.get("value").and_then(Value::as_str)?.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: 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(), + id, + }) + }) + .collect(); + } + if let Some(commands) = commands { + caps.slash_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(); + } +} + +/// 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-"; + +/// 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" => { + let request_id = self + .write_or_queue_control(serde_json::json!({ + "subtype": "apply_flag_settings", + "settings": { "effortLevel": value }, + })) + .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)" + ); + } + + /// 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" + ); + } + + /// 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")); + } + + /// Mode read/advertise parity: claude advertises its FIXED 4 permission modes in + /// `available_modes` (so the picker has data). 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 4 fixed modes are advertised with the EXACT wire ids claude accepts. + let caps = backend.capabilities(); + let ids: Vec<&str> = caps.available_modes.iter().map(|m| m.id.as_str()).collect(); + assert_eq!( + ids, + vec!["default", "plan", "acceptEdits", "bypassPermissions"], + "claude advertises its fixed permission-mode enum (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"); + } + + /// 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..8333da329 --- /dev/null +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -0,0 +1,5820 @@ +//! 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, 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): codex's collaborationMode for SetMode + // requires settings.model, so the backend must track it from the start. + 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(); + backend.capabilities.current_mode = config.mode.clone(); + + // 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,model} + // (Fresh) | 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?; + + 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() + } +} + +/// 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 + model threaded from config. +/// +/// 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); + } + if let Some(model) = &config.model { + params["model"] = json!(model); + } + 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>>, + /// 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, + Modes, + Checkpoints, + Rewind, +} + +/// Per-session handshake-discovered capability lists (B-CODEX-MODEL-LIST). +#[derive(Default, Clone)] +struct Discovered { + models: Vec, + 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_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_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 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_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, + 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?; + let mode_list_id = self.next_rpc_id(); + self.pending_discovery + .lock() + .await + .insert(mode_list_id, DiscoveryKind::Modes); + self.write_frame(json!({ + "jsonrpc": "2.0", "id": mode_list_id, "method": "collaborationMode/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_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, &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; + } + emit( + &event_tx, + &session_id, + cur, + SessionEvent::PermissionResolved { + request_id: req_id.map(|v| v.to_string()).unwrap_or_default(), + 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::Modes => { + fill_discovery(kind, result, &discovered); + } + 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) 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, + }); +} + +/// 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::Modes => { + let arr = list("data", "modes"); + let present = arr.is_some(); + let modes = arr + .map(|arr| { + arr.iter() + .filter_map(|m| { + // id = the lowercase `mode` token SetMode sends; fall back to + // `name` only if `mode` is absent (older shape). + let id = m + .get("mode") + .and_then(Value::as_str) + .or_else(|| m.get("name").and_then(Value::as_str))? + .to_string(); + let name = m.get("name").and_then(Value::as_str).unwrap_or(&id).to_string(); + Some(ModeInfo { + id, + name, + description: None, + }) + }) + .collect::>() + }) + .unwrap_or_default(); + if present && modes.is_empty() { + tracing::warn!( + "codex collaborationMode/list parsed to empty (wire shape may have drifted from result.data[])" + ); + } + 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) +} + +/// 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. +async fn handle_reverse_rpc( + method: &str, + frame: &Value, + session_id: &str, + turn_gen: &Arc, + event_tx: &broadcast::Sender, + pending_auth_id: &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(); + 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")), + }); + 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. Carries threadSettings.{model, + // collaborationMode.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("collaborationMode") + .and_then(|c| c.get("mode")) + .and_then(Value::as_str) + .map(str::to_string); + 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?; + // codex `thread/settings/update{threadId, collaborationMode}` (M1). + // ⚠️ collaborationMode is an OBJECT, not a bare string: schema + // CollaborationMode = `{mode: ModeKind[plan|default], settings: + // {model: }}`. A bare string fails deserialization. We + // therefore build the object with the tracked current_model; without + // a known model we CANNOT form a valid frame → reject (rather than + // ship a frame codex rejects). Overrides apply to the NEXT turn; + // confirmed via the thread/settings/updated notif. + let model = self.current_model.lock().await.clone(); + let Some(model) = model else { + return Err(BackendError::Transport( + "codex SetMode needs a known model for collaborationMode.settings (set a model first)".into(), + )); + }; + 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 mode) 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}{mode}")); + let frame = json!({ + "jsonrpc": "2.0", "id": id, "method": "thread/settings/update", + "params": { + "threadId": tid, + "collaborationMode": { "mode": mode, "settings": { "model": model } } + } + }); + 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?; + 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), + }) + } + // #99: codex's effort is a collaborationMode reasoning_effort setting set + // via SetMode (thread/settings), not a generic config-option command — so + // the generic SetConfigOption has no codex wire and rejects (cap=false ↔ + // reject). (If a codex effort-via-SetConfigOption path is wired later, it + // routes through thread/settings like SetMode.) + 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 + collaborationMode/list responses). 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 + } +} + +/// 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:?}" + ); + } + } + + #[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 + collaborationMode.mode. + let events = drive_codex(&[ + r#"{"jsonrpc":"2.0","method":"thread/settings/updated","params":{"threadId":"th1","threadSettings":{"model":"gpt-5.5","collaborationMode":{"mode":"plan","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 == "plan" + )), + "thread/settings/updated → ConfigChanged{{model,mode}}, 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_mode_writes_collaboration_mode_object() { + // M1: SetMode → `thread/settings/update{threadId, collaborationMode}` where + // collaborationMode is the OBJECT {mode, settings:{model}} (NOT a bare + // string). Requires a known model — set one first via SetModel. + 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::SetModel { + model: "gpt-5.5".into(), + }) + .await + .expect("set model"); + backend + .dispatch(Command::SetMode { mode: "plan".into() }) + .await + .expect("accepted"); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""method":"thread/settings/update""#), + "wrote thread/settings/update, got: {written}" + ); + // The OBJECT form, not a bare string — assert nested mode + settings.model. + assert!( + written.contains(r#""collaborationMode":{"mode":"plan","settings":{"model":"gpt-5.5"}}"#), + "collaborationMode is the {{mode,settings{{model}}}} object, got: {written}" + ); + assert!( + !written.contains(r#""collaborationMode":"plan""#), + "must NOT send the bare-string form codex rejects" + ); + } + + #[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_without_known_model_is_rejected() { + // M1: collaborationMode.settings.model is REQUIRED; with no known model the + // backend cannot form a valid frame → reject rather than ship a bad one. + let fake = fake_with_binding("th-6", None); + let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; + let err = backend + .dispatch(Command::SetMode { mode: "plan".into() }) + .await + .expect_err("no known model → reject"); + assert!(matches!(err, BackendError::Transport(m) if m.contains("needs a known model"))); + } + + #[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 + model from config; approvalPolicy/sandbox + /// are valid codex enum values. + #[test] + fn thread_start_frame_threads_cwd_and_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_eq!(frame["params"]["model"], "gpt-5.5"); + 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"); + } + + /// 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 + collaborationMode/list) ===== + + #[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}}"#; + let mode_resp = r#"{"jsonrpc":"2.0","id":51,"result":{"data":[{"name":"Plan","mode":"plan","model":null,"reasoning_effort":"medium"},{"name":"Default","mode":"default","model":null,"reasoning_effort":null}]}}"#; + let bytes = format!("{model_resp}\n{mode_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::Modes); + } + // 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 + ); + assert_eq!( + caps.available_modes.len(), + 2, + "collaborationMode/list result.data[] filled available_modes" + ); + assert!( + caps.available_modes.iter().any(|m| m.id == "plan" && m.name == "Plan"), + "mode id = the lowercase `mode` token SetMode sends (NOT the display name), got {:?}", + caps.available_modes + ); + } + + /// Cross-version fallback (README discipline #9): if a future codex renamed the + /// wrapper back to `models`/`modes` or emitted bare-string reasoning efforts, the + /// parser must still degrade gracefully (data-first, legacy-fallback) — so a rename + /// in either direction never silently empties the list again. + #[tokio::test] + async fn fill_discovery_accepts_legacy_models_modes_keys_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(); + let mode_result: Value = serde_json::from_str(r#"{"modes":[{"name":"plan"}]}"#).unwrap(); + fill_discovery(DiscoveryKind::Models, &model_result, &discovered); + fill_discovery(DiscoveryKind::Modes, &mode_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" + ); + // mode with only `name` (no `mode` token) falls back to name as the id. + assert_eq!(d.modes.len(), 1, "legacy result.modes[] still parses"); + assert_eq!(d.modes[0].id, "plan", "mode id falls back to `name` when `mode` absent"); + } + + // ===== 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..7ce829032 --- /dev/null +++ b/crates/aionui-session/src/backend/mod.rs @@ -0,0 +1,232 @@ +//! 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: backends without a pending registry (codex's only pending is + /// auth, not a tool permission; ACP `session/request_permission` is + /// fire-and-forget) and every test double need no change. claude overrides it. + 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..c8d63f07b --- /dev/null +++ b/crates/aionui-session/src/capability.rs @@ -0,0 +1,201 @@ +//! `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. + 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). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +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). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +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). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +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..7f8a38459 --- /dev/null +++ b/crates/aionui-session/src/event.rs @@ -0,0 +1,1599 @@ +//! `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, + }, + + /// ⭐ 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 { .. } + | 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 + 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..2ffb19da0 --- /dev/null +++ b/crates/aionui-session/src/reducer.rs @@ -0,0 +1,2270 @@ +//! 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 { .. } + | 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 + ); + } +} From 8523a9ecf60d2fc2e53f5bb004cd3c4f354689ad Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Mon, 6 Jul 2026 21:09:47 +0800 Subject: [PATCH 02/22] fix(session-port): gate codex model and claude permission-mode against catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related first-turn robustness fixes for fresh sessions: - codex: stop binding the persisted model at thread/start. A stale frontend picker default (e.g. gpt-5.5) not present on the local codex poisoned the whole thread, failing every turn with UNKNOWN_UPSTREAM_ERROR. Now thread/start carries no model; after handshake, spawn a reconcile task that learns the real catalog from model/list, drops the desire if invalid, and applies a valid one via SetModel — a faithful port of the ACP clear_invalid_desired_model + separate set_model RPC path. - claude: validate config.mode against CLAUDE_PERMISSION_MODE_IDS before passing it as --permission-mode. An unknown mode makes claude spawn exit 1 (hard crash); we now warn and fall back to "default". - Cargo.lock: sync workspace version bump and aion-config dependency. --- Cargo.lock | 5 +- .../aionui-session/src/backend/claude_conn.rs | 59 +++++ .../aionui-session/src/backend/codex_conn.rs | 227 +++++++++++++++++- 3 files changed, 279 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c3f3ca6d..6f45fed0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -392,6 +392,7 @@ dependencies = [ name = "aionui-app" version = "0.1.42" dependencies = [ + "aion-config", "aionui-ai-agent", "aionui-api-types", "aionui-assets", @@ -740,7 +741,7 @@ dependencies = [ [[package]] name = "aionui-process" -version = "0.1.40" +version = "0.1.42" dependencies = [ "aionui-common", "aionui-runtime", @@ -801,7 +802,7 @@ dependencies = [ [[package]] name = "aionui-session" -version = "0.1.40" +version = "0.1.42" dependencies = [ "aionui-common", "aionui-process", diff --git a/crates/aionui-session/src/backend/claude_conn.rs b/crates/aionui-session/src/backend/claude_conn.rs index cc29e0956..55c494526 100644 --- a/crates/aionui-session/src/backend/claude_conn.rs +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -160,11 +160,32 @@ pub(crate) fn build_claude_init_args(config: &SessionConfig) -> Vec { // "default" (standard prompts) so a session with no explicit choice is gated, not // bypassed. `default`/`plan`/`acceptEdits`/`bypassPermissions` are claude's exact // wire values (see `claude_permission_modes`). + // 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()); @@ -2544,6 +2565,44 @@ mod tests { ); } + /// 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. + for valid in ["default", "plan", "acceptEdits", "bypassPermissions"] { + 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) falls back to + // `default` instead of crashing the spawn. + for invalid in ["yolo", "yoloNoSandbox", "auto", "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() { diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs index 8333da329..4c899b835 100644 --- a/crates/aionui-session/src/backend/codex_conn.rs +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -96,6 +96,10 @@ impl BackendConnection for CodexConnection { let mut backend = CodexSessionBackend::spawn_with_wake(logical_id, io, wake, config.idle_ttl_ms).await; // Seed the current model (M1): codex's collaborationMode for SetMode // requires settings.model, so the backend must track it from the start. + // OPTIMISTIC seed: the model is not actually bound at thread/start anymore + // (codex-model-gating). `spawn_codex_model_reconcile` clears this back to None + // if the requested model turns out NOT to be in the discovered catalog, so a + // later SetMode never builds a collaborationMode around a model codex rejects. if let Some(model) = &config.model { *backend.current_model.lock().await = Some(model.clone()); } @@ -112,8 +116,9 @@ impl BackendConnection for CodexConnection { // 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,model} - // (Fresh) | thread/resume{threadId}. The threadId comes back BOTH in the + // initialize{clientInfo} → thread/start{approvalPolicy,sandbox,cwd} + // (Fresh; NO model — applied later via a validated SetModel, see + // spawn_codex_model_reconcile) | 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 @@ -126,6 +131,22 @@ impl BackendConnection for CodexConnection { }; backend.run_handshake(resume_tid.as_deref()).await?; + // codex-model-gating: `thread/start` intentionally did NOT bind `config.model` + // (see `thread_start_params`) — the thread launched on codex's own default + // model. Now apply the requested model the ACP way: wait for `model/list` to + // fill the catalog, then dispatch a VALIDATED `SetModel` (dropped if the model + // is not in the catalog, so a stale picker default never poisons every turn). + // Detached + off the open hot path; a Fresh session with a requested model is + // the only case that does any work (Resume keeps codex's bound model; no + // requested model → nothing to reconcile). + if matches!(spec, SessionSpec::Fresh { .. }) + && let Some(model) = config.model.clone() + { + let backend = Arc::new(backend); + spawn_codex_model_reconcile(backend.clone(), model); + return Ok(backend); + } + Ok(Arc::new(backend)) } @@ -145,6 +166,80 @@ impl BackendConnection for CodexConnection { } } +/// How long the post-handshake model reconcile waits for `model/list` to fill the +/// 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 model (we cannot validate it), leaving the +/// thread on codex's launch default rather than risk binding a bad model. +const CODEX_MODEL_RECONCILE_POLLS: u32 = 100; + +/// codex-model-gating self-heal (the ACP `clear_invalid_desired_model` + +/// `reconcile_session` port). `thread/start` launched the thread on codex's OWN +/// default model (it deliberately did not embed `config.model`). This detached task +/// waits for `model/list` to fill the discovered catalog, then: +/// - if `requested` IS in the catalog → dispatch a `SetModel` so the thread uses it +/// (the 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. This is the exact ACP contract (`session/new` carried +/// no model; the model was applied only after being validated against the +/// session/new catalog), adapted to codex's `model/list`-after-`thread/start` +/// ordering. +fn spawn_codex_model_reconcile(backend: Arc, requested: String) { + tokio::spawn(async move { + // Wait for the catalog (model/list response → `discovered.models`). The reader + // fills it asynchronously after `run_handshake` fired the request. + let mut catalog: Vec = Vec::new(); + for _ in 0..CODEX_MODEL_RECONCILE_POLLS { + { + let disc = backend.discovered.lock().unwrap_or_else(|e| e.into_inner()); + if !disc.models.is_empty() { + catalog = disc.models.iter().map(|m| m.id.clone()).collect(); + break; + } + } + tokio::time::sleep(std::time::Duration::from_millis(50)).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"); + } + }); +} + /// 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). @@ -183,7 +278,21 @@ fn initialize_params() -> HandshakeParams { } /// `thread/start` params (Fresh / lost-Resume). approvalPolicy/sandbox are valid -/// AskForApproval/SandboxMode enum values; cwd + model threaded from config. +/// 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` @@ -207,9 +316,8 @@ fn thread_start_params(config: &SessionConfig) -> HandshakeParams { if let Some(cwd) = &config.cwd { params["cwd"] = json!(cwd); } - if let Some(model) = &config.model { - params["model"] = json!(model); - } + // 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) }); } @@ -4875,10 +4983,13 @@ mod tests { assert_eq!(frame["params"]["clientInfo"]["name"], "aionui-session"); } - /// thread/start params thread cwd + model from config; approvalPolicy/sandbox - /// are valid codex enum values. + /// 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` (`spawn_codex_model_reconcile`), never here. #[test] - fn thread_start_frame_threads_cwd_and_model() { + 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()), @@ -4887,7 +4998,11 @@ mod tests { .into_frame(2, "thread/start"); assert_eq!(frame["method"], "thread/start"); assert_eq!(frame["params"]["cwd"], "/work"); - assert_eq!(frame["params"]["model"], "gpt-5.5"); + 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 @@ -5132,6 +5247,98 @@ mod tests { assert_eq!(d.modes[0].id, "plan", "mode id falls back to `name` when `mode` absent"); } + // ===== 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 + /// `spawn_codex_model_reconcile` 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; + spawn_codex_model_reconcile(backend.clone(), "openai.gpt-5.4".into()); + 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 (await the spawned task's effect by polling). + spawn_codex_model_reconcile(backend.clone(), "gpt-5.5-that-local-codex-lacks".into()); + // Give the detached task time to observe the catalog + drop the seed. + let mut cleared = false; + for _ in 0..40 { + if backend.current_model.lock().await.is_none() { + cleared = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!( + cleared, + "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() + } + // ===== O2: thread/turns/list response → CheckpointList event (up-leg) ===== /// Gap-2: `dispatch(ListCheckpoints)` writes `thread/turns/list` AND the reader From 37a6c548ff0ab83d4ca034e4e46f8000af09a9a0 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Tue, 7 Jul 2026 11:37:48 +0800 Subject: [PATCH 03/22] fix(session-port): close four ACP parity gaps on claude/codex Port the ACP reconcile mechanisms (clear_invalid_desired_* + validated apply) to the direct-CLI SessionBackend path: - codex mode reconcile: apply config.mode via a validated SetMode after collaborationMode/list fills the catalog (codex has no thread/start mode param, so mode was never applied at open before). Sequenced after the model reconcile since SetMode needs a settled current_model. Drop (WARN) if not in catalog; empty catalog = permissive. - claude effort validation: validate SetConfigOption{effort} against the current model's reasoning_efforts before sending; reject unsupported levels instead of poisoning current_effort. Empty catalog = permissive. - runtime invalid mode/model switch: session_agent set_config_option now validates mode/model against the advertised catalog before dispatch and returns BadRequest (reject + report) rather than silently dropping. - effort persistence: persist the chosen effort into config_selections (claude emits no ConfigChanged for effort) and re-apply it after open, so it survives respawn/resume. --- crates/aionui-ai-agent/src/session_agent.rs | 225 ++++++++++++- .../aionui-session/src/backend/claude_conn.rs | 120 +++++++ .../aionui-session/src/backend/codex_conn.rs | 305 ++++++++++++------ 3 files changed, 558 insertions(+), 92 deletions(-) diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index c17176186..f434dd1e0 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -42,6 +42,14 @@ 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"; + /// Shared, cheaply-cloneable runtime state for a session task: the broadcast sender /// the translator writes and `subscribe()` reads, plus liveness bookkeeping. struct SessionRuntime { @@ -109,6 +117,13 @@ pub struct SessionAgentTask { 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>, /// Command-id counter for `CommandMeta` (dispatch correlation). command_seq: AtomicI64, } @@ -144,13 +159,14 @@ impl SessionAgentTask { // 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); + spawn_event_pump(events, runtime.clone(), conversation_id.clone(), session_repo.clone()); Arc::new(Self { agent_type, conversation_id, workspace, backend, runtime, + session_repo, command_seq: AtomicI64::new(0), }) } @@ -218,6 +234,7 @@ impl SessionAgentTask { /// - 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, @@ -355,6 +372,41 @@ impl SessionAgentTask { 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(), @@ -385,6 +437,19 @@ impl SessionAgentTask { 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" => { + // 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, + }); + } _ => { return Ok(aionui_api_types::SetConfigOptionResponse { confirmation: aionui_api_types::ConfigOptionConfirmation::CommandAck, @@ -409,6 +474,49 @@ impl SessionAgentTask { }) } + /// 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> { @@ -743,11 +851,48 @@ pub async fn build_session_instance( } } + // #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 { @@ -2274,6 +2419,49 @@ mod persist_tests { 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" + ); + } + // ── 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 @@ -2781,7 +2969,7 @@ mod pump_tests { /// 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. - struct StaticCapsBackend; + pub(super) struct StaticCapsBackend; #[async_trait::async_trait] impl SessionBackend for StaticCapsBackend { @@ -2872,6 +3060,39 @@ mod pump_tests { 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 diff --git a/crates/aionui-session/src/backend/claude_conn.rs b/crates/aionui-session/src/backend/claude_conn.rs index 55c494526..47a0debe3 100644 --- a/crates/aionui-session/src/backend/claude_conn.rs +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -753,6 +753,46 @@ impl ClaudeSessionBackend { /// 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); @@ -2187,6 +2227,20 @@ impl SessionBackend for ClaudeSessionBackend { // (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" + ))); + } let request_id = self .write_or_queue_control(serde_json::json!({ "subtype": "apply_flag_settings", @@ -3791,6 +3845,72 @@ mod tests { 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")); + } + /// Mode read/advertise parity: claude advertises its FIXED 4 permission modes in /// `available_modes` (so the picker has data). current_mode is now REMEMBERED from /// claude's inbound `system/status{permissionMode}` (design §9.10.1 option A — diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs index 4c899b835..7dd752df5 100644 --- a/crates/aionui-session/src/backend/codex_conn.rs +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -97,7 +97,7 @@ impl BackendConnection for CodexConnection { // Seed the current model (M1): codex's collaborationMode for SetMode // requires settings.model, so the backend must track it from the start. // OPTIMISTIC seed: the model is not actually bound at thread/start anymore - // (codex-model-gating). `spawn_codex_model_reconcile` clears this back to None + // (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 // later SetMode never builds a collaborationMode around a model codex rejects. if let Some(model) = &config.model { @@ -118,7 +118,7 @@ impl BackendConnection for CodexConnection { // aion-probe transcripts in protocols/verification fixtures): // initialize{clientInfo} → thread/start{approvalPolicy,sandbox,cwd} // (Fresh; NO model — applied later via a validated SetModel, see - // spawn_codex_model_reconcile) | thread/resume{threadId}. The threadId comes back BOTH in the + // 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 @@ -133,17 +133,18 @@ impl BackendConnection for CodexConnection { // codex-model-gating: `thread/start` intentionally did NOT bind `config.model` // (see `thread_start_params`) — the thread launched on codex's own default - // model. Now apply the requested model the ACP way: wait for `model/list` to - // fill the catalog, then dispatch a VALIDATED `SetModel` (dropped if the model - // is not in the catalog, so a stale picker default never poisons every turn). - // Detached + off the open hot path; a Fresh session with a requested model is - // the only case that does any work (Resume keeps codex's bound model; no - // requested model → nothing to reconcile). - if matches!(spec, SessionSpec::Fresh { .. }) - && let Some(model) = config.model.clone() - { + // model. Now apply the requested model+mode the ACP way: wait for `model/list` / + // `collaborationMode/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 bound model+mode; no requested config → nothing to reconcile). The two + // are SEQUENCED (model first): SetMode's collaborationMode requires a known + // current_model, so the mode reconcile runs only after the model reconcile has + // settled current_model. + if matches!(spec, SessionSpec::Fresh { .. }) && (config.model.is_some() || config.mode.is_some()) { let backend = Arc::new(backend); - spawn_codex_model_reconcile(backend.clone(), model); + spawn_codex_reconcile(backend.clone(), config.model.clone(), config.mode.clone()); return Ok(backend); } @@ -166,78 +167,143 @@ impl BackendConnection for CodexConnection { } } -/// How long the post-handshake model reconcile waits for `model/list` to fill the +/// 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 model (we cannot validate it), leaving the -/// thread on codex's launch default rather than risk binding a bad model. -const CODEX_MODEL_RECONCILE_POLLS: u32 = 100; - -/// codex-model-gating self-heal (the ACP `clear_invalid_desired_model` + -/// `reconcile_session` port). `thread/start` launched the thread on codex's OWN -/// default model (it deliberately did not embed `config.model`). This detached task -/// waits for `model/list` to fill the discovered catalog, then: -/// - if `requested` IS in the catalog → dispatch a `SetModel` so the thread uses it -/// (the 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. This is the exact ACP contract (`session/new` carried -/// no model; the model was applied only after being validated against the -/// session/new catalog), adapted to codex's `model/list`-after-`thread/start` -/// ordering. -fn spawn_codex_model_reconcile(backend: Arc, requested: String) { +/// 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 { - // Wait for the catalog (model/list response → `discovered.models`). The reader - // fills it asynchronously after `run_handshake` fired the request. - let mut catalog: Vec = Vec::new(); - for _ in 0..CODEX_MODEL_RECONCILE_POLLS { - { - let disc = backend.discovered.lock().unwrap_or_else(|e| e.into_inner()); - if !disc.models.is_empty() { - catalog = disc.models.iter().map(|m| m.id.clone()).collect(); - break; - } - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + if let Some(model) = model { + reconcile_codex_model(&backend, model).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 let Some(mode) = mode { + reconcile_codex_mode(&backend, mode).await; } + }); +} - 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; +/// 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() +} - // 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)" +/// 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)" ); - if let Err(e) = backend.dispatch(Command::SetModel { model: requested }).await { - tracing::error!(error = %e, "codex model reconcile: SetModel dispatch failed"); - } - }); + 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`]. codex has +/// NO `thread/start` mode param — a thread always launches on codex's default +/// collaborationMode — so `config.mode` was never applied at open before this (unlike +/// claude, which passes `--permission-mode` at spawn). This closes that gap AND +/// validates: wait for `collaborationMode/list` to fill the catalog, then apply a +/// `SetMode` if `requested` IS in the catalog (validated apply), or DROP it (WARN, +/// leaving codex's default) if it is NOT. Empty/never-populated catalog → do NOT apply +/// (cannot validate). Runs only after [`reconcile_codex_model`] so `SetMode` can build +/// its `collaborationMode` around a settled `current_model` (SetMode rejects outright +/// without a known model). +async fn reconcile_codex_mode(backend: &CodexSessionBackend, requested: String) { + let catalog = await_codex_catalog(backend, |d| d.modes.iter().map(|m| m.id.clone()).collect()).await; + + if catalog.is_empty() { + tracing::warn!( + requested_mode = %requested, + "codex mode reconcile: collaborationMode/list never populated; leaving thread on codex default \ + (requested mode NOT applied — cannot validate)" + ); + return; + } + + if !catalog.contains(&requested) { + tracing::warn!( + requested_mode = %requested, + catalog = ?catalog, + "codex mode reconcile: requested mode not in catalog; dropping it \ + (thread stays on codex default)" + ); + return; + } + + tracing::info!( + mode = %requested, + "codex mode reconcile: applying requested mode (validated against catalog)" + ); + if let Err(e) = backend.dispatch(Command::SetMode { mode: requested }).await { + // SetMode with no known current_model rejects here (Transport error). That is + // expected when the model reconcile dropped an invalid model: no model → no + // valid collaborationMode → mode simply stays on codex's default. WARN, not ERROR. + tracing::warn!(error = %e, "codex mode reconcile: SetMode dispatch failed (likely no current_model); mode not applied"); + } } /// A pure params object that knows how to wrap itself in a JSON-RPC request frame. @@ -4987,7 +5053,7 @@ mod tests { /// 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` (`spawn_codex_model_reconcile`), never here. + /// 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 { @@ -5251,7 +5317,7 @@ mod tests { /// 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 - /// `spawn_codex_model_reconcile` runs against. Returns (Arc, captured + /// `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"}}}"#; @@ -5286,7 +5352,7 @@ mod tests { #[tokio::test] async fn codex_model_reconcile_applies_valid_model() { let (backend, captured) = backend_with_catalog_and_binding().await; - spawn_codex_model_reconcile(backend.clone(), "openai.gpt-5.4".into()); + reconcile_codex_model(&backend, "openai.gpt-5.4".into()).await; let written = captured_str(&captured).await; assert!( written.contains(r#""method":"thread/settings/update""#), @@ -5309,19 +5375,10 @@ mod tests { 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 (await the spawned task's effect by polling). - spawn_codex_model_reconcile(backend.clone(), "gpt-5.5-that-local-codex-lacks".into()); - // Give the detached task time to observe the catalog + drop the seed. - let mut cleared = false; - for _ in 0..40 { - if backend.current_model.lock().await.is_none() { - cleared = true; - break; - } - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } + // Run the reconcile inline (awaited directly — no detached task). + reconcile_codex_model(&backend, "gpt-5.5-that-local-codex-lacks".into()).await; assert!( - cleared, + 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; @@ -5339,6 +5396,74 @@ mod tests { 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 a two-mode catalog + /// (`default`, `plan`) AND bound a thread — the state `reconcile_codex_mode` runs + /// against. Returns (Arc, captured stdin). The caller seeds `current_model` + /// (SetMode needs it to build a collaborationMode). + 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 mode_resp = r#"{"jsonrpc":"2.0","id":60,"result":{"data":[{"mode":"default","name":"Default"},{"mode":"plan","name":"Plan"}],"nextCursor":null}}"#; + let bytes = format!("{started}\n{mode_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::Modes); + 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 mode that IS in the discovered catalog is applied via a real + /// `thread/settings/update{collaborationMode}` (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 mode param, so this is + /// ALSO the first time `config.mode` reaches codex at all. + #[tokio::test] + async fn codex_mode_reconcile_applies_valid_mode() { + let (backend, captured) = backend_with_mode_catalog_and_binding().await; + // SetMode needs a known current_model to build collaborationMode.settings. + *backend.current_model.lock().await = Some("openai.gpt-5.4".into()); + reconcile_codex_mode(&backend, "plan".into()).await; + let written = captured_str(&captured).await; + assert!( + written.contains(r#""method":"thread/settings/update""#), + "a catalog-valid mode is applied via thread/settings/update, got: {written}" + ); + assert!( + written.contains(r#""collaborationMode""#) && written.contains(r#""mode":"plan""#), + "carries the requested (valid) mode inside collaborationMode, got: {written}" + ); + } + + /// A requested mode that is NOT in the catalog is DROPPED: no `thread/settings/update` + /// is written (the thread stays on codex's default collaborationMode). Port of ACP's + /// `clear_invalid_desired_mode`. + #[tokio::test] + async fn codex_mode_reconcile_drops_invalid_mode() { + let (backend, captured) = backend_with_mode_catalog_and_binding().await; + *backend.current_model.lock().await = Some("openai.gpt-5.4".into()); + reconcile_codex_mode(&backend, "mode-local-codex-lacks".into()).await; + let written = captured_str_allow_empty(&captured).await; + assert!( + !written.contains(r#""method":"thread/settings/update""#), + "an invalid mode must NOT be applied to codex (no thread/settings/update), got: {written}" + ); + } + // ===== O2: thread/turns/list response → CheckpointList event (up-leg) ===== /// Gap-2: `dispatch(ListCheckpoints)` writes `thread/turns/list` AND the reader From 4e9cfe0c6b4bfa46c3a1e0d37459eaf7344f497e Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Tue, 7 Jul 2026 14:52:10 +0800 Subject: [PATCH 04/22] fix(session-port): emit empty-turn diagnostic tip on claude/codex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The direct-CLI session-port path (claude/codex via SessionAgentTask) let a clean "blank reply" turn — the model reached a normal terminal without emitting any user-visible output — finish silently, leaving the user with an empty bubble. The legacy ACP path already surfaces a diagnostic tip for this (ELECTRON-1JG) via is_empty_turn + prompt_outcome_from_stop_reason; the session-port pump did not. Mirror that behavior off the already-normalized TurnOutcome (no stderr peek, no watchdog, no timeout): track a per-turn saw_visible_output flag in the event pump, and on a clean TurnResult (is_error:false, not cancelled, not a suppressed workflow-launch) with no visible output, emit AgentStreamEvent::Tips with a code mapped 1:1 to the ACP codes — EndTurn -> ACP_EMPTY_TURN (info); MaxTokens/MaxTurns/Refused -> ACP_EMPTY_TURN_{MAX_TOKENS,MAX_TURN_REQUESTS, REFUSAL} (warning); other truncation kinds and Failed -> generic warning; Cancelled -> no tip. The Tips is emitted immediately BEFORE the Finish because the relay breaks the turn on Finish, so a tip after it would be dropped. Cargo.lock: sync aionui-process/aionui-session to the workspace 0.1.43 version. --- Cargo.lock | 4 +- crates/aionui-ai-agent/src/session_agent.rs | 204 +++++++++++++++++++- 2 files changed, 205 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1aec2dc7e..46fcfe672 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -741,7 +741,7 @@ dependencies = [ [[package]] name = "aionui-process" -version = "0.1.42" +version = "0.1.43" dependencies = [ "aionui-common", "aionui-runtime", @@ -802,7 +802,7 @@ dependencies = [ [[package]] name = "aionui-session" -version = "0.1.42" +version = "0.1.43" dependencies = [ "aionui-common", "aionui-process", diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index f434dd1e0..9093e1a13 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -24,7 +24,9 @@ use crate::agent_task::IAgentTask; use crate::error::AgentError; use crate::protocol::events::session_updates::ThinkingEventData; use crate::protocol::events::tool_call::{ToolCallEventData, ToolCallStatus}; -use crate::protocol::events::{AgentStreamEvent, FinishEventData, StartEventData, TextEventData}; +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; @@ -1180,13 +1182,29 @@ fn spawn_event_pump( // 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(); + // 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; 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 { @@ -1246,11 +1264,31 @@ fn spawn_event_pump( SessionEvent::TurnStarted { .. } => runtime.set_status(ConversationStatus::Running), SessionEvent::TurnResult { .. } | SessionEvent::Detached { .. } if !suppress_intermediate_finish => { runtime.set_status(ConversationStatus::Finished); + // 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. @@ -1272,6 +1310,23 @@ fn spawn_event_pump( // 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 @@ -1507,6 +1562,61 @@ fn stamp_tool_name(names: &mut std::collections::HashMap, ev: &m /// 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, + }) +} + fn translate_event(event: SessionEvent, _conversation_id: &str) -> Vec { match event { // NOTE: the Start lifecycle frame is emitted by `send_message` (before @@ -2338,6 +2448,98 @@ mod translate_tests { // Must NOT also emit a Finish (Error is the terminal). assert!(!events.iter().any(|e| matches!(e, AgentStreamEvent::Finish(_)))); } + + // --- 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)] From 6ee5685c1af98b13e24df397ab03f20e4a432241 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Tue, 7 Jul 2026 17:57:27 +0800 Subject: [PATCH 05/22] fix(session-port): broadcast CatalogUpdated so model picker fills async claude/codex resolve their selectable model/mode/slash-command catalog asynchronously (~6s after session open), unlike ACP which carries it in the NewSessionResponse body. The direct-CLI shell filled the capability cache on arrival but never pushed it upward, so the top-right model picker stayed empty until a manual reload. Add a CatalogUpdated session event emitted when the catalog resolves (claude control_request{initialize} response; codex model/list + collaborationMode/list). SessionAgentTask projects it to an acp_config_option frame so the frontend re-fetches config-options and fills the picker. Marked Ephemeral (re-discovered on open, not history). --- crates/aionui-ai-agent/src/session_agent.rs | 144 ++++++++++++++++++ crates/aionui-session/src/adapter.rs | 1 + .../aionui-session/src/backend/claude_conn.rs | 142 +++++++++++++---- .../aionui-session/src/backend/codex_conn.rs | 69 +++++++++ crates/aionui-session/src/capability.rs | 9 +- crates/aionui-session/src/event.rs | 24 +++ crates/aionui-session/src/reducer.rs | 4 + 7 files changed, 360 insertions(+), 33 deletions(-) diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index 9093e1a13..7b2ed448d 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -1221,6 +1221,73 @@ fn spawn_event_pump( 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(), + }); + } + // 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)); + } + 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 @@ -1795,6 +1862,10 @@ fn translate_event(event: SessionEvent, _conversation_id: &str) -> Vec 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 @@ -2922,6 +2993,79 @@ mod pump_tests { ); } + // 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 backend: Arc = Arc::new(ScriptBackend(script)); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let frames = drain(&task).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" + ); + } + // 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)] diff --git a/crates/aionui-session/src/adapter.rs b/crates/aionui-session/src/adapter.rs index fb8b05cc9..bb1e9b306 100644 --- a/crates/aionui-session/src/adapter.rs +++ b/crates/aionui-session/src/adapter.rs @@ -14,6 +14,7 @@ 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). diff --git a/crates/aionui-session/src/backend/claude_conn.rs b/crates/aionui-session/src/backend/claude_conn.rs index 47a0debe3..661a5e30b 100644 --- a/crates/aionui-session/src/backend/claude_conn.rs +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -1144,7 +1144,7 @@ async fn reader_task( // 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); + 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 @@ -1594,7 +1594,13 @@ fn sniff_mode( /// 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>) { +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") { @@ -1616,37 +1622,64 @@ fn sniff_control_initialize(frame: &serde_json::Value, discovered_caps: &Arc = models + .map(|models| { + models + .iter() + .filter_map(|m| { + let id = m.get("value").and_then(Value::as_str)?.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: 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(), + id, + }) }) - }) - .collect(); - } - if let Some(commands) = commands { - caps.slash_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 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(); + .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 @@ -4361,6 +4394,55 @@ mod tests { 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 { + match tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await { + Ok(Some(env)) => { + if 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 fixed permission modes must ride along (whole-snapshot replace). + let mode_ids: Vec<&str> = modes.iter().map(|m| m.id.as_str()).collect(); + assert_eq!( + mode_ids, + vec!["default", "plan", "acceptEdits", "bypassPermissions"], + "the fixed 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. diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs index 7dd752df5..99d277e30 100644 --- a/crates/aionui-session/src/backend/codex_conn.rs +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -1238,6 +1238,29 @@ async fn reader_task( match kind { DiscoveryKind::Models | DiscoveryKind::Modes => { 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 + // collaborationMode/list are SEPARATE responses; emit a + // full snapshot of whatever `discovered` holds now, so the + // first arrival already lights the picker and the second + // refines it. Without this the frontend, which read an + // empty `config_options` on open, never re-fetches and the + // model selector stays disabled. + 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( @@ -5286,6 +5309,52 @@ mod tests { ); } + /// 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}}"#; + let mode_resp = r#"{"jsonrpc":"2.0","id":51,"result":{"data":[{"name":"Plan","mode":"plan"}]}}"#; + let bytes = format!("{model_resp}\n{mode_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::Modes); + } + 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 { + match tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await { + Ok(Some(env)) => { + if 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, "plan"); + 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 /// wrapper back to `models`/`modes` or emitted bare-string reasoning efforts, the /// parser must still degrade gracefully (data-first, legacy-fallback) — so a rename diff --git a/crates/aionui-session/src/capability.rs b/crates/aionui-session/src/capability.rs index c8d63f07b..76d4ad8fe 100644 --- a/crates/aionui-session/src/capability.rs +++ b/crates/aionui-session/src/capability.rs @@ -73,7 +73,8 @@ pub struct Capabilities { /// #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). -#[derive(Debug, Clone, PartialEq, Eq, Default)] +/// 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, @@ -154,7 +155,8 @@ pub enum PromptAcceptedSource { } /// 007 §9.10: an advertised model (discovery, open set). -#[derive(Debug, Clone, PartialEq, Eq, Default)] +/// 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, @@ -163,7 +165,8 @@ pub struct ModelInfo { } /// 007 §9.10: an advertised mode (discovery, open set). -#[derive(Debug, Clone, PartialEq, Eq, Default)] +/// 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, diff --git a/crates/aionui-session/src/event.rs b/crates/aionui-session/src/event.rs index 7f8a38459..66087a651 100644 --- a/crates/aionui-session/src/event.rs +++ b/crates/aionui-session/src/event.rs @@ -293,6 +293,28 @@ pub enum SessionEvent { 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, + 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 @@ -729,6 +751,7 @@ pub fn classify(event: &SessionEvent) -> EventClass { | Provisioning { .. } | Rewound { .. } | ConfigChanged { .. } + | CatalogUpdated { .. } | SubagentUpdate { .. } | SubagentDetail { .. } | Notice { .. } @@ -768,6 +791,7 @@ pub fn persist_tier(event: &SessionEvent) -> PersistTier { 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) diff --git a/crates/aionui-session/src/reducer.rs b/crates/aionui-session/src/reducer.rs index 2ffb19da0..303e55854 100644 --- a/crates/aionui-session/src/reducer.rs +++ b/crates/aionui-session/src/reducer.rs @@ -387,6 +387,10 @@ pub fn step(state: &SessionState, event: SessionEvent) -> (SessionState, Vec Date: Wed, 8 Jul 2026 10:23:20 +0800 Subject: [PATCH 06/22] fix(session-port): make claude/codex permission modes verbatim-equivalent to legacy ACP Codex (Plan B): backend translates the colon profile ids (:read-only/:workspace/:danger-full-access) to the legacy bare tokens (read-only/auto/full-access) on the way out and back on the way in, so the frontend receives byte-identical values and needs zero change; custom profiles keep their colon id. Fresh sessions seed current_mode=auto. Claude: claude_permission_modes() now reproduces the legacy bridge buildAvailableModes verbatim (default, acceptEdits, plan, dontAsk, bypassPermissions). auto is omitted because the bridge gates it on supportsAutoMode, which the direct CLI never reports; the validation whitelist still accepts auto (superset) so a resumed session carrying it does not crash the spawn. bypassPermissions is always advertised. --- crates/aionui-ai-agent/src/session_agent.rs | 36 +- crates/aionui-session/src/adapter/claude.rs | 62 +- .../aionui-session/src/backend/claude_conn.rs | 49 +- .../aionui-session/src/backend/codex_conn.rs | 821 ++++++++++++++---- crates/aionui-session/src/capability.rs | 12 +- crates/aionui-session/src/event.rs | 5 + 6 files changed, 779 insertions(+), 206 deletions(-) diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index 7b2ed448d..e832487fa 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -1079,20 +1079,32 @@ fn catalog_partial_from_caps(caps: &aionui_session::Capabilities) -> Option) -> Option<&'static str> { match mode.map(str::trim) { - Some("full-access" | "yoloNoSandbox") => Some("danger-full-access"), + Some(":danger-full-access" | "full-access" | "yoloNoSandbox") => Some("danger-full-access"), _ => 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"`. Verbatim port of -/// clean-slate `session_runtime::codex_approval_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) { - Some("full-access" | "yoloNoSandbox") => Some("never"), + Some(":danger-full-access" | "full-access" | "yoloNoSandbox") => Some("never"), _ => None, } } @@ -2128,15 +2140,24 @@ mod build_mapping_tests { /// other mode (and None) keeps None ⇒ workspace-write. Ported verbatim. #[test] fn codex_sandbox_maps_only_full_access_modes() { + // Plan B canonical value: the legacy bare token. 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(" full-access ")), + codex_sandbox_for_mode(Some(" :danger-full-access ")), Some("danger-full-access") ); + // read-only and workspace tiers do NOT escalate — the safe sandbox default applies. + assert_eq!(codex_sandbox_for_mode(Some(":read-only")), None); + 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); @@ -2146,9 +2167,12 @@ mod build_mapping_tests { /// (→ "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("full-access")), Some("never")); assert_eq!(codex_approval_for_mode(Some("yoloNoSandbox")), Some("never")); - assert_eq!(codex_approval_for_mode(Some(" full-access ")), 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); diff --git a/crates/aionui-session/src/adapter/claude.rs b/crates/aionui-session/src/adapter/claude.rs index f658917d5..a528db8f3 100644 --- a/crates/aionui-session/src/adapter/claude.rs +++ b/crates/aionui-session/src/adapter/claude.rs @@ -62,13 +62,23 @@ enum StreamBlockKind { } /// The EXACT wire values claude's `--permission-mode` / `set_permission_mode` -/// accept — the SINGLE source for both the advertised mode picker -/// ([`claude_permission_modes`]) and the seed-time whitelist +/// 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. -const CLAUDE_PERMISSION_MODE_IDS: [&str; 4] = ["default", "plan", "acceptEdits", "bypassPermissions"]; +/// +/// 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 @@ -79,17 +89,47 @@ pub fn is_valid_claude_permission_mode(mode: &str) -> bool { CLAUDE_PERMISSION_MODE_IDS.contains(&mode) } -/// claude's FIXED permission-mode enum, advertised as `available_modes` so the UI -/// mode picker has data (the write path — `--permission-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; only name/description are display copy. Unlike -/// models/effort these are static (no `initialize`-response discovery). +/// 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 permission prompts"), - ("plan", "Plan", "Plan first, no edits until approved"), - ("acceptEdits", "Accept Edits", "Auto-accept file edits"), - ("bypassPermissions", "Bypass Permissions", "Skip all permission prompts"), + ("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 { diff --git a/crates/aionui-session/src/backend/claude_conn.rs b/crates/aionui-session/src/backend/claude_conn.rs index 661a5e30b..83ade9a6a 100644 --- a/crates/aionui-session/src/backend/claude_conn.rs +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -158,8 +158,10 @@ pub(crate) fn build_claude_init_args(config: &SessionConfig) -> Vec { // 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`/`plan`/`acceptEdits`/`bypassPermissions` are claude's exact - // wire values (see `claude_permission_modes`). + // 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 @@ -2671,17 +2673,21 @@ mod tests { .position(|a| a == "--permission-mode") .and_then(|i| args.get(i + 1).cloned()) }; - // Every valid enum value passes through verbatim. - for valid in ["default", "plan", "acceptEdits", "bypassPermissions"] { + // 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) falls back to - // `default` instead of crashing the spawn. - for invalid in ["yolo", "yoloNoSandbox", "auto", "acceptedits", "danger", "Plan"] { + // 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"), @@ -3944,12 +3950,15 @@ mod tests { assert_eq!(backend.capabilities().current_effort.as_deref(), Some("high")); } - /// Mode read/advertise parity: claude advertises its FIXED 4 permission modes in - /// `available_modes` (so the picker has data). 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. + /// 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 @@ -3958,13 +3967,14 @@ mod tests { let fake = FakeAgentIo::never_exits(format!("{status}\n").into_bytes()); let backend = ClaudeSessionBackend::build_with_io("s", Box::new(fake)).await; - // The 4 fixed modes are advertised with the EXACT wire ids claude accepts. + // 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", "plan", "acceptEdits", "bypassPermissions"], - "claude advertises its fixed permission-mode enum (picker data source)" + vec!["default", "acceptEdits", "plan", "dontAsk", "bypassPermissions"], + "claude advertises the legacy-equivalent permission-mode picker (picker data source)" ); assert!( caps.available_modes @@ -4432,12 +4442,13 @@ mod tests { assert_eq!(models.len(), 2, "parsed models ride the event"); assert_eq!(models[0].id, "default"); assert_eq!(models[1].id, "opus"); - // claude's fixed permission modes must ride along (whole-snapshot replace). + // 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", "plan", "acceptEdits", "bypassPermissions"], - "the fixed permission modes ride the catalog event so the mode picker survives the snapshot replace" + 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"); diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs index 99d277e30..f6c8df3f7 100644 --- a/crates/aionui-session/src/backend/codex_conn.rs +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -94,12 +94,14 @@ impl BackendConnection for CodexConnection { config: config.clone(), }; let mut backend = CodexSessionBackend::spawn_with_wake(logical_id, io, wake, config.idle_ttl_ms).await; - // Seed the current model (M1): codex's collaborationMode for SetMode - // requires settings.model, so the backend must track it from the start. - // OPTIMISTIC seed: the model is not actually bound at thread/start anymore - // (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 - // later SetMode never builds a collaborationMode around a model codex rejects. + // 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()); } @@ -111,7 +113,29 @@ impl BackendConnection for CodexConnection { // 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(); - backend.capabilities.current_mode = config.mode.clone(); + // Present the current mode in the SAME value space as the catalog (frontend-facing + // legacy bare token): a persisted colon id (`:danger-full-access` from older data) + // is mapped to its bare form so the picker highlights the matching catalog entry. + // + // 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::profile_id_to_legacy_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 @@ -132,16 +156,17 @@ impl BackendConnection for CodexConnection { backend.run_handshake(resume_tid.as_deref()).await?; // codex-model-gating: `thread/start` intentionally did NOT bind `config.model` - // (see `thread_start_params`) — the thread launched on codex's own default - // model. Now apply the requested model+mode the ACP way: wait for `model/list` / - // `collaborationMode/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 bound model+mode; no requested config → nothing to reconcile). The two - // are SEQUENCED (model first): SetMode's collaborationMode requires a known - // current_model, so the mode reconcile runs only after the model reconcile has - // settled current_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()); @@ -262,31 +287,45 @@ async fn reconcile_codex_model(backend: &CodexSessionBackend, requested: String) } } -/// Apply `requested` mode the ACP way, symmetric to [`reconcile_codex_model`]. codex has -/// NO `thread/start` mode param — a thread always launches on codex's default -/// collaborationMode — so `config.mode` was never applied at open before this (unlike -/// claude, which passes `--permission-mode` at spawn). This closes that gap AND -/// validates: wait for `collaborationMode/list` to fill the catalog, then apply a -/// `SetMode` if `requested` IS in the catalog (validated apply), or DROP it (WARN, +/// 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). Runs only after [`reconcile_codex_model`] so `SetMode` can build -/// its `collaborationMode` around a settled `current_model` (SetMode rejects outright -/// without a known model). +/// (cannot validate). Unlike the old collaborationMode path this needs no settled +/// `current_model`. async fn reconcile_codex_mode(backend: &CodexSessionBackend, requested: String) { - let catalog = await_codex_catalog(backend, |d| d.modes.iter().map(|m| m.id.clone()).collect()).await; + // 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: collaborationMode/list never populated; leaving thread on codex default \ + "codex mode reconcile: permissionProfile/list never populated; leaving thread on codex default \ (requested mode NOT applied — cannot validate)" ); return; } - if !catalog.contains(&requested) { + // 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)" @@ -295,14 +334,14 @@ async fn reconcile_codex_mode(backend: &CodexSessionBackend, requested: String) } tracing::info!( - mode = %requested, + mode = %normalized, "codex mode reconcile: applying requested mode (validated against catalog)" ); - if let Err(e) = backend.dispatch(Command::SetMode { mode: requested }).await { - // SetMode with no known current_model rejects here (Transport error). That is - // expected when the model reconcile dropped an invalid model: no model → no - // valid collaborationMode → mode simply stays on codex's default. WARN, not ERROR. - tracing::warn!(error = %e, "codex mode reconcile: SetMode dispatch failed (likely no current_model); mode not applied"); + 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"); } } @@ -578,7 +617,9 @@ pub struct CodexSessionBackend { #[derive(Clone, Copy)] enum DiscoveryKind { Models, - Modes, + /// 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, } @@ -587,6 +628,8 @@ enum DiscoveryKind { #[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, } @@ -938,13 +981,19 @@ impl CodexSessionBackend { "params": { "includeHidden": false } })) .await?; - let mode_list_id = self.next_rpc_id(); + // 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(mode_list_id, DiscoveryKind::Modes); + .insert(perm_list_id, DiscoveryKind::Permissions); self.write_frame(json!({ - "jsonrpc": "2.0", "id": mode_list_id, "method": "collaborationMode/list", "params": {} + "jsonrpc": "2.0", "id": perm_list_id, "method": "permissionProfile/list", "params": {} })) .await?; Ok(()) @@ -1236,17 +1285,18 @@ async fn reader_task( && let Some(result) = frame.get("result") { match kind { - DiscoveryKind::Models | DiscoveryKind::Modes => { + 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 - // collaborationMode/list are SEPARATE responses; emit a + // permissionProfile/list are SEPARATE responses; emit a // full snapshot of whatever `discovered` holds now, so the - // first arrival already lights the picker and the second - // refines it. Without this the frontend, which read an - // empty `config_options` on open, never re-fetches and the - // model selector stays disabled. + // 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()) @@ -1395,6 +1445,125 @@ fn emit(tx: &broadcast::Sender, session_id: &str, turn_gen: u64 }); } +/// 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) + /// - `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 { + "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(), + } + } + + /// 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. /// @@ -1471,34 +1640,64 @@ fn fill_discovery(kind: DiscoveryKind, result: &Value, discovered: &Arc { - let arr = list("data", "modes"); + 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(|m| { - // id = the lowercase `mode` token SetMode sends; fall back to - // `name` only if `mode` is absent (older shape). - let id = m - .get("mode") + .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) - .or_else(|| m.get("name").and_then(Value::as_str))? - .to_string(); - let name = m.get("name").and_then(Value::as_str).unwrap_or(&id).to_string(); + .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, + id: codex_perm::profile_id_to_legacy_value(profile_id), name, - description: None, + description, }) }) .collect::>() }) .unwrap_or_default(); if present && modes.is_empty() { - tracing::warn!( - "codex collaborationMode/list parsed to empty (wire shape may have drifted from result.data[])" - ); + tracing::warn!("codex permissionProfile/list parsed to empty (no profile with an id)"); } discovered.lock().unwrap_or_else(|e| e.into_inner()).modes = modes; } @@ -1796,8 +1995,19 @@ fn map_notification(method: &str, params: &Value) -> 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. Carries threadSettings.{model, - // collaborationMode.mode}. + // 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") @@ -1805,10 +2015,13 @@ fn map_notification(method: &str, params: &Value) -> Vec { .unwrap_or(&Value::Null); let model = settings.get("model").and_then(Value::as_str).map(str::to_string); let mode = settings - .get("collaborationMode") - .and_then(|c| c.get("mode")) + .get("activePermissionProfile") + .and_then(|p| p.get("id")) .and_then(Value::as_str) - .map(str::to_string); + // 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 @@ -2649,31 +2862,35 @@ impl SessionBackend for CodexSessionBackend { self.suspend .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) .await?; - // codex `thread/settings/update{threadId, collaborationMode}` (M1). - // ⚠️ collaborationMode is an OBJECT, not a bare string: schema - // CollaborationMode = `{mode: ModeKind[plan|default], settings: - // {model: }}`. A bare string fails deserialization. We - // therefore build the object with the tracked current_model; without - // a known model we CANNOT form a valid frame → reject (rather than - // ship a frame codex rejects). Overrides apply to the NEXT turn; - // confirmed via the thread/settings/updated notif. - let model = self.current_model.lock().await.clone(); - let Some(model) = model else { - return Err(BackendError::Transport( - "codex SetMode needs a known model for collaborationMode.settings (set a model first)".into(), - )); - }; + // 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 mode) surfaces as a Notice instead of + // 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}{mode}")); + 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, - "collaborationMode": { "mode": mode, "settings": { "model": model } } + "permissions": profile_id } }); self.write_frame(frame).await?; @@ -2916,7 +3133,8 @@ impl SessionBackend for CodexSessionBackend { 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 + collaborationMode/list responses). Read-only sync lock. + // 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() { @@ -4622,18 +4840,42 @@ mod tests { #[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 + collaborationMode.mode. + // 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","collaborationMode":{"mode":"plan","settings":{"model":"gpt-5.5"}}}}}"#, + 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: Some(md) } if m == "gpt-5.5" && md == "plan" + SessionEvent::ConfigChanged { model: Some(m), mode: None } if m == "gpt-5.5" )), - "thread/settings/updated → ConfigChanged{{model,mode}}, got {events:?}" + "null activePermissionProfile → ConfigChanged with mode:None, got {events:?}" ); } @@ -4706,36 +4948,81 @@ mod tests { } #[tokio::test] - async fn dispatch_set_mode_writes_collaboration_mode_object() { - // M1: SetMode → `thread/settings/update{threadId, collaborationMode}` where - // collaborationMode is the OBJECT {mode, settings:{model}} (NOT a bare - // string). Requires a known model — set one first via SetModel. + 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::SetModel { - model: "gpt-5.5".into(), + .dispatch(Command::SetMode { + mode: ":danger-full-access".into(), }) .await - .expect("set model"); - backend - .dispatch(Command::SetMode { mode: "plan".into() }) - .await .expect("accepted"); let written = captured_str(&captured).await; assert!( written.contains(r#""method":"thread/settings/update""#), "wrote thread/settings/update, got: {written}" ); - // The OBJECT form, not a bare string — assert nested mode + settings.model. assert!( - written.contains(r#""collaborationMode":{"mode":"plan","settings":{"model":"gpt-5.5"}}"#), - "collaborationMode is the {{mode,settings{{model}}}} object, got: {written}" + 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#""collaborationMode":"plan""#), - "must NOT send the bare-string form codex rejects" + !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}" ); } @@ -4790,16 +5077,25 @@ mod tests { } #[tokio::test] - async fn dispatch_set_mode_without_known_model_is_rejected() { - // M1: collaborationMode.settings.model is REQUIRED; with no known model the - // backend cannot form a valid frame → reject rather than ship a bad one. + 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; - let err = backend + backend .dispatch(Command::SetMode { mode: "plan".into() }) .await - .expect_err("no known model → reject"); - assert!(matches!(err, BackendError::Transport(m) if m.contains("needs a known model"))); + .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] @@ -5237,7 +5533,32 @@ mod tests { assert_eq!(spec.cwd.as_deref(), Some("/tmp/work"), "cwd threaded (workspace)"); } - // ===== B-CODEX-MODEL-LIST: discovery (model/list + collaborationMode/list) ===== + // ===== 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() { @@ -5252,8 +5573,10 @@ mod tests { // + 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}}"#; - let mode_resp = r#"{"jsonrpc":"2.0","id":51,"result":{"data":[{"name":"Plan","mode":"plan","model":null,"reasoning_effort":"medium"},{"name":"Default","mode":"default","model":null,"reasoning_effort":null}]}}"#; - let bytes = format!("{model_resp}\n{mode_resp}\n").into_bytes(); + // 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 @@ -5261,7 +5584,7 @@ mod tests { { let mut pd = backend.pending_discovery.lock().await; pd.insert(50, DiscoveryKind::Models); - pd.insert(51, DiscoveryKind::Modes); + pd.insert(51, DiscoveryKind::Permissions); } // Subscribe to drive the reader; it consumes the two responses → fill_discovery. let _events = backend.events(); @@ -5297,14 +5620,14 @@ mod tests { "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.len(), - 2, - "collaborationMode/list result.data[] filled available_modes" - ); - assert!( - caps.available_modes.iter().any(|m| m.id == "plan" && m.name == "Plan"), - "mode id = the lowercase `mode` token SetMode sends (NOT the display name), got {:?}", + 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 ); } @@ -5320,33 +5643,32 @@ mod tests { 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}}"#; - let mode_resp = r#"{"jsonrpc":"2.0","id":51,"result":{"data":[{"name":"Plan","mode":"plan"}]}}"#; - let bytes = format!("{model_resp}\n{mode_resp}\n").into_bytes(); + // 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::Modes); + 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 { - match tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await { - Ok(Some(env)) => { - if 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, "plan"); - saw_both = true; - break; - } - } - _ => {} + 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!( @@ -5356,20 +5678,18 @@ mod tests { } /// Cross-version fallback (README discipline #9): if a future codex renamed the - /// wrapper back to `models`/`modes` or emitted bare-string reasoning efforts, 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 - /// in either direction never silently empties the list again. + /// never silently empties the model list again. #[tokio::test] - async fn fill_discovery_accepts_legacy_models_modes_keys_and_string_efforts() { + 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(); - let mode_result: Value = serde_json::from_str(r#"{"modes":[{"name":"plan"}]}"#).unwrap(); fill_discovery(DiscoveryKind::Models, &model_result, &discovered); - fill_discovery(DiscoveryKind::Modes, &mode_result, &discovered); let d = discovered.lock().unwrap(); assert_eq!(d.models.len(), 1, "legacy result.models[] still parses"); assert_eq!( @@ -5377,9 +5697,137 @@ mod tests { vec!["low".to_string(), "high".to_string()], "bare-string efforts still accepted" ); - // mode with only `name` (no `mode` token) falls back to name as the id. - assert_eq!(d.modes.len(), 1, "legacy result.modes[] still parses"); - assert_eq!(d.modes[0].id, "plan", "mode id falls back to `name` when `mode` absent"); + } + + /// 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). + for legacy in ["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"); + } + + /// 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 ===== @@ -5467,18 +5915,20 @@ mod tests { // ===== codex-mode-gating: post-handshake validated mode reconcile ===== - /// Build a backend whose reader has already learned a two-mode catalog - /// (`default`, `plan`) AND bound a thread — the state `reconcile_codex_mode` runs - /// against. Returns (Arc, captured stdin). The caller seeds `current_model` - /// (SetMode needs it to build a collaborationMode). + /// 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 mode_resp = r#"{"jsonrpc":"2.0","id":60,"result":{"data":[{"mode":"default","name":"Default"},{"mode":"plan","name":"Plan"}],"nextCursor":null}}"#; - let bytes = format!("{started}\n{mode_resp}\n").into_bytes(); + 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::Modes); + backend.pending_discovery.lock().await.insert(60, DiscoveryKind::Permissions); let _events = backend.events(); for _ in 0..40 { let filled = !backend @@ -5496,40 +5946,73 @@ mod tests { (Arc::new(backend), captured) } - /// A requested mode that IS in the discovered catalog is applied via a real - /// `thread/settings/update{collaborationMode}` (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 mode param, so this is - /// ALSO the first time `config.mode` reaches codex at all. + /// 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; - // SetMode needs a known current_model to build collaborationMode.settings. - *backend.current_model.lock().await = Some("openai.gpt-5.4".into()); - reconcile_codex_mode(&backend, "plan".into()).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 mode is applied via thread/settings/update, got: {written}" + "a catalog-valid tier is applied via thread/settings/update, got: {written}" ); assert!( - written.contains(r#""collaborationMode""#) && written.contains(r#""mode":"plan""#), - "carries the requested (valid) mode inside collaborationMode, got: {written}" + written.contains(r#""permissions":":danger-full-access""#), + "carries the requested tier as its colon profile id, got: {written}" ); } - /// A requested mode that is NOT in the catalog is DROPPED: no `thread/settings/update` - /// is written (the thread stays on codex's default collaborationMode). Port of ACP's - /// `clear_invalid_desired_mode`. + /// 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_drops_invalid_mode() { + async fn codex_mode_reconcile_normalizes_legacy_persisted_value() { let (backend, captured) = backend_with_mode_catalog_and_binding().await; - *backend.current_model.lock().await = Some("openai.gpt-5.4".into()); - reconcile_codex_mode(&backend, "mode-local-codex-lacks".into()).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""#), - "an invalid mode must NOT be applied to codex (no thread/settings/update), got: {written}" + "a tier absent from the (partial) catalog must NOT be applied, got: {written}" ); } diff --git a/crates/aionui-session/src/capability.rs b/crates/aionui-session/src/capability.rs index 76d4ad8fe..60947d5d0 100644 --- a/crates/aionui-session/src/capability.rs +++ b/crates/aionui-session/src/capability.rs @@ -30,7 +30,17 @@ pub struct Capabilities { /// 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. + /// 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). diff --git a/crates/aionui-session/src/event.rs b/crates/aionui-session/src/event.rs index 66087a651..b7505a753 100644 --- a/crates/aionui-session/src/event.rs +++ b/crates/aionui-session/src/event.rs @@ -311,6 +311,11 @@ pub enum SessionEvent { /// 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, }, From 2565a1382dc860516492a3dd8a46d727318df8b3 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Wed, 8 Jul 2026 16:39:56 +0800 Subject: [PATCH 07/22] fix(session-port): classify Detached as crash to restore ACP disconnect terminal The direct-CLI bridge collapsed every SessionEvent::Detached to a plain Finish, so a CLI that died mid-reply rendered as a normal empty completion instead of the legacy ACP path's UserAgentDisconnected error card. Track terminal_result_seen in the event pump (mirroring the reducer's Running flag) and route Detached through aionui_session::crash_outcome so a genuine mid-turn crash (signal / non-zero / unknown exit, no prior result) surfaces as AgentStreamEvent::Error via the same AcpError::Disconnected classifier legacy used, with the allowlisted redacted_summary on the message. Absorbed teardowns (Detached after a terminal result) and clean exit-0 blank turns still end with a plain Finish. Adds three unit tests covering crash, post-terminal teardown, and clean exit-0. --- crates/aionui-ai-agent/src/session_agent.rs | 174 +++++++++++++++++++- 1 file changed, 168 insertions(+), 6 deletions(-) diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index e832487fa..6b546f09d 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -1200,6 +1200,15 @@ fn spawn_event_pump( // 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"); @@ -1340,9 +1349,18 @@ fn spawn_event_pump( // 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), + SessionEvent::TurnStarted { .. } => { + runtime.set_status(ConversationStatus::Running); + // New turn: the prior turn's terminal no longer applies. + terminal_result_seen = false; + } SessionEvent::TurnResult { .. } | SessionEvent::Detached { .. } if !suppress_intermediate_finish => { runtime.set_status(ConversationStatus::Finished); + // 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 @@ -1382,7 +1400,7 @@ fn spawn_event_pump( 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) { + 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 @@ -1696,7 +1714,11 @@ fn empty_turn_tip(outcome: &aionui_session::TurnOutcome) -> Option Vec { +/// `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(). @@ -1782,8 +1804,45 @@ fn translate_event(event: SessionEvent, _conversation_id: &str) -> Vec { - 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 @@ -1821,7 +1880,7 @@ fn translate_event(event: SessionEvent, _conversation_id: &str) -> Vec Option<(TipType, String)> { From 9603ade566d39c9d54d84710fc3d298ff304fc38 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Wed, 8 Jul 2026 16:40:07 +0800 Subject: [PATCH 08/22] docs(agents): add no-guessing rule for agent-CLI behavior claims Forbid inferring any agent CLI's wire protocol / message shapes / field semantics / timing / defaults from names, mental models, or training knowledge. Every such claim must cite an approved source by path (captured samples, the ACP library, or an official adapter / machine-generated schema). A claim with no citation is a guess and violates the rule. --- AGENTS.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e51e8bebe..34afd9b21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,36 @@ 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: From e9a0d5b65311a7541ea651a70f3675180a41e9f2 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Thu, 9 Jul 2026 13:49:41 +0800 Subject: [PATCH 09/22] fix(session): preload cold-start catalog from persisted handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session-port direct-CLI path (SessionAgentTask) served the model/mode picker purely off the backend's live capabilities(), which is empty until the initialize round-trip lands (~seconds on resume). So reopening a history conversation showed a blank model list until the async catalog arrived — the ACP path avoids this via preload_metadata_catalogs reading the persisted agent_metadata handshake. Mirror that on the session path: parse the persisted handshake into a CatalogPreload (reusing the ACP extract_models_from_value/extract_modes_from_value multi-shape parser) and serve it as a per-axis fallback in get_model / get_config_options / mode when live capabilities are still empty. Live catalog overwrites the preload the instant it is present (fill-when-empty, live-overwrites), matching the ACP semantics — so a stale persisted catalog never masks a fresh engine's model list. The write side (spawn_catalog_writeback) was already wired; this connects the missing read side. --- Cargo.lock | 4 +- crates/aionui-ai-agent/src/session_agent.rs | 298 ++++++++++++++++++-- 2 files changed, 284 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 501709c58..96a848a15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -742,7 +742,7 @@ dependencies = [ [[package]] name = "aionui-process" -version = "0.1.43" +version = "0.1.44" dependencies = [ "aionui-common", "aionui-runtime", @@ -803,7 +803,7 @@ dependencies = [ [[package]] name = "aionui-session" -version = "0.1.43" +version = "0.1.44" dependencies = [ "aionui-common", "aionui-process", diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index 6b546f09d..c826a42ce 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -112,6 +112,75 @@ impl SessionRuntime { } } +/// 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, @@ -126,6 +195,15 @@ pub struct SessionAgentTask { /// 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, } @@ -147,6 +225,48 @@ impl SessionAgentTask { 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 { @@ -169,6 +289,7 @@ impl SessionAgentTask { backend, runtime, session_repo, + catalog_preload, command_seq: AtomicI64::new(0), }) } @@ -276,12 +397,51 @@ impl SessionAgentTask { 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). Each axis (models, modes) falls back independently, so a + /// backend that answers modes-before-models still serves the preloaded models in + /// the gap. Live overwrites preload the instant it lands (non-empty live wins). + /// Currents likewise prefer live, then preload. Returns owned data (the getters + /// map it into wire DTOs). + fn effective_catalog( + &self, + ) -> ( + Vec, + Option, + Vec, + Option, + ) { + let caps = self.backend.capabilities(); + let (models, current_model) = if caps.available_models.is_empty() { + ( + self.catalog_preload.available_models.clone(), + self.catalog_preload.current_model.clone(), + ) + } else { + (caps.available_models, caps.current_model) + }; + let (modes, current_mode) = if caps.available_modes.is_empty() { + ( + self.catalog_preload.available_modes.clone(), + self.catalog_preload.current_mode.clone(), + ) + } else { + (caps.available_modes, caps.current_mode) + }; + (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(caps.current_mode).unwrap_or_default(), + mode: self.runtime.mode_override().or(current_mode).unwrap_or_default(), initialized: true, }) } @@ -290,20 +450,21 @@ impl SessionAgentTask { /// "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 { - let caps = self.backend.capabilities(); + // 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 caps.available_models.is_empty() && caps.current_model.is_none() && override_model.is_none() { + 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 = caps - .available_models + 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_else(|| caps.current_model.clone()); + 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())); @@ -321,9 +482,11 @@ impl SessionAgentTask { /// 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 { - let caps = self.backend.capabilities(); + // 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(); let mut config_options = Vec::new(); - if !caps.available_modes.is_empty() { + if !modes.is_empty() { config_options.push(aionui_api_types::AcpConfigOptionDto { id: "mode".into(), name: Some("Mode".into()), @@ -331,9 +494,8 @@ impl SessionAgentTask { description: None, category: Some("mode".into()), option_type: "select".into(), - current_value: self.runtime.mode_override().or_else(|| caps.current_mode.clone()), - options: caps - .available_modes + current_value: self.runtime.mode_override().or(current_mode), + options: modes .iter() .map(|m| aionui_api_types::AcpConfigSelectOptionDto { value: m.id.clone(), @@ -344,7 +506,7 @@ impl SessionAgentTask { .collect(), }); } - if !caps.available_models.is_empty() { + if !models.is_empty() { config_options.push(aionui_api_types::AcpConfigOptionDto { id: "model".into(), name: Some("Model".into()), @@ -352,9 +514,8 @@ impl SessionAgentTask { description: None, category: Some("model".into()), option_type: "select".into(), - current_value: self.runtime.model_override().or_else(|| caps.current_model.clone()), - options: caps - .available_models + current_value: self.runtime.model_override().or(current_model), + options: models .iter() .map(|m| aionui_api_types::AcpConfigSelectOptionDto { value: m.id.clone(), @@ -901,7 +1062,14 @@ pub async fn build_session_instance( spawn_catalog_writeback(agent_id, backend.clone(), catalog_tx); } - let task = SessionAgentTask::new(AgentType::Acp, conversation_id, workspace, backend, acp_session_repo); + 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))) } @@ -3734,4 +3902,102 @@ mod pump_tests { .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")); + } } From 87e6ee9fcf45df1bceea154ce5071668fe77a015 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Thu, 9 Jul 2026 17:25:56 +0800 Subject: [PATCH 10/22] fix(session): serve backend-seeded current model/mode in cold-start picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit effective_catalog gated the current model/mode fallback on the LIST being empty: pre-init (live capabilities not yet discovered) it served the persisted-handshake preload's current, which is frozen at the prior session's catalog write-back and does not reflect a mid-turn model switch. The backend, however, seeds caps.current_model/current_mode from the resolved snapshot at spawn (the persisted current_model_id column) — already the user's last interactive selection, and the model claude actually runs. Decouple the per-axis fallback: the LIST still prefers live-then-preload, but the CURRENT prefers caps (snapshot-seeded) then preload. This removes the pre-init window where the picker briefly showed a stale model after the user switched models last session, aligning it with the ACP path (which seeds current_model into the session at construction). Adds caps_current_wins_over_stale_preload_while_list_is_empty covering the "current seeded, list still empty" case the old list-gated logic could not distinguish. --- crates/aionui-ai-agent/src/session_agent.rs | 111 +++++++++++++++++--- 1 file changed, 94 insertions(+), 17 deletions(-) diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index c826a42ce..818b41ff2 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -399,11 +399,19 @@ impl SessionAgentTask { /// 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). Each axis (models, modes) falls back independently, so a - /// backend that answers modes-before-models still serves the preloaded models in - /// the gap. Live overwrites preload the instant it lands (non-empty live wins). - /// Currents likewise prefer live, then preload. Returns owned data (the getters - /// map it into wire DTOs). + /// 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, ) -> ( @@ -413,22 +421,20 @@ impl SessionAgentTask { Option, ) { let caps = self.backend.capabilities(); - let (models, current_model) = if caps.available_models.is_empty() { - ( - self.catalog_preload.available_models.clone(), - self.catalog_preload.current_model.clone(), - ) + let models = if caps.available_models.is_empty() { + self.catalog_preload.available_models.clone() } else { - (caps.available_models, caps.current_model) + caps.available_models }; - let (modes, current_mode) = if caps.available_modes.is_empty() { - ( - self.catalog_preload.available_modes.clone(), - self.catalog_preload.current_mode.clone(), - ) + 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, caps.current_mode) + caps.available_modes }; + let current_mode = caps.current_mode.or_else(|| self.catalog_preload.current_mode.clone()); (models, current_model, modes, current_mode) } @@ -4000,4 +4006,75 @@ mod pump_tests { ); 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"); + } } From a8b7dd2f84cd06e498b6aa3b71715684d8dd9753 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Thu, 9 Jul 2026 17:26:10 +0800 Subject: [PATCH 11/22] fix(session): terminate claude turn on process exit without stdout EOF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claude reader's unbounded read (post-first-frame) relied solely on stdout EOF (Ok(0)) or a read error to terminate a turn. On Windows the claude stdout write handle can be inherited by a surviving grandchild (a detached MCP/tool descendant); killing the claude leaf then leaves the pipe's write end open, so stdout.read() never returns 0 — the reader parks forever, Detached never fires, and the UI wedges at pending with no error. (macOS fds are close-on-exec, so EOF is prompt and the reader terminates normally.) Race the unbounded read against the process's exit watch (io.wait_for_exit(), a cancel-safe watch::Receiver over the direct child's child.wait(), orthogonal to the stdout pipe). When the exit leg wins, bounded-drain any buffered tail (EOF may never come, so we cannot wait for Ok(0)) via the same process_batch + panic net, then break to the existing terminal path reusing the captured exit status (no re-await of wait_for_exit). Scope: claude_conn only; codex/acp connections are persistent ACP whose stdout does not EOF between turns and must not adopt this. The startup handshake-budget read, the hung/panicked (exit:None) paths, and flush_tail/R1a are unchanged. biased select prefers the read leg so a turn that emits then exits still delivers its result frame before terminating. Adds process_exit_without_eof_surfaces_terminal_detached (mutation-proven: a bare read hangs it) — the mirror of the long-silent-turn-not-killed test, pinning terminate-on-real-exit vs never-on-mere-silence. --- .../aionui-session/src/backend/claude_conn.rs | 170 +++++++++++++++++- 1 file changed, 165 insertions(+), 5 deletions(-) diff --git a/crates/aionui-session/src/backend/claude_conn.rs b/crates/aionui-session/src/backend/claude_conn.rs index 83ade9a6a..f87f4ed0a 100644 --- a/crates/aionui-session/src/backend/claude_conn.rs +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -1269,6 +1269,20 @@ async fn reader_task( // 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 @@ -1285,8 +1299,68 @@ async fn reader_task( ); } let read = if seen_frame { - // Proven alive → unbounded read (a long turn is never timed). - stdout.read(&mut chunk).await.map_err(|_| ()) + // 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 { @@ -1397,11 +1471,16 @@ async fn reader_task( // 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). On a real - // EOF/exit we 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). + // 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 }; @@ -3081,6 +3160,87 @@ mod tests { ); } + /// 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 From b39d619828bc8c030e325392ae527983264456b7 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Fri, 10 Jul 2026 15:55:19 +0800 Subject: [PATCH 12/22] fix(session): surface per-model reasoning effort as a config option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session-port direct-CLI path (SessionAgentTask) discovered per-model reasoning-effort levels in its backend (Capabilities.current_effort + ModelInfo.reasoning_efforts) but never forwarded them to the frontend as a config option, so the top-right effort/thinking picker silently disappeared for claude/codex on session-port — a parity regression vs the legacy ACP path. Surface the effort levels as a `reasoning_effort` config option (category `thought_level`) in both the REST getter (get_config_options) and the streaming CatalogUpdated push, resolving levels for the current model (union fallback when no current model is set). Because claude emits no ConfigChanged/echo for effort, track the picker highlight through a new effort_override cell and let set_config_option fall through to an observed re-read matched by category. --- crates/aionui-ai-agent/src/session_agent.rs | 261 +++++++++++++++++++- 1 file changed, 249 insertions(+), 12 deletions(-) diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index 818b41ff2..14c730e41 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -52,6 +52,26 @@ const PERM_REJECT: &str = "reject"; /// `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 { @@ -75,6 +95,13 @@ struct SessionRuntime { /// 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 { @@ -110,6 +137,14 @@ impl SessionRuntime { 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` @@ -276,6 +311,7 @@ impl SessionAgentTask { 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 @@ -491,6 +527,9 @@ impl SessionAgentTask { // 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 { @@ -532,6 +571,35 @@ impl SessionAgentTask { .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 }) } @@ -601,12 +669,17 @@ impl SessionAgentTask { // 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. - // Non-mode/model options (effort/thought_level) have no picker current_value - // to confirm — they report CommandAck (accepted, applied live). + // 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 @@ -614,10 +687,6 @@ impl SessionAgentTask { // + 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, - }); } _ => { return Ok(aionui_api_types::SetConfigOptionResponse { @@ -627,10 +696,20 @@ impl SessionAgentTask { } } 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| o.id == option_id) + .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 { @@ -1473,6 +1552,35 @@ fn spawn_event_pump( .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() @@ -3095,6 +3203,138 @@ mod persist_tests { ); } + // 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 @@ -3284,11 +3524,8 @@ mod pump_tests { 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). - loop { - match tokio::time::timeout(std::time::Duration::from_millis(300), rx.recv()).await { - Ok(Ok(ev)) => out.push(ev), - _ => break, - } + while let Ok(Ok(ev)) = tokio::time::timeout(std::time::Duration::from_millis(300), rx.recv()).await { + out.push(ev); } out } From 8c6a78952bbd78a73a55d07c5afdc5d4095889ca Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Fri, 10 Jul 2026 16:15:23 +0800 Subject: [PATCH 13/22] fix(session): push slash commands when the catalog arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude advertises its slash-command list in the same async `initialize` response that carries the model/mode catalog — long after the frontend's mount-time REST read of the command list returns empty. The legacy ACP path recovers by emitting a live `AvailableCommands` frame from its `AvailableCommandsUpdate` handler, but the session-port pump destructured `CatalogUpdated.slash_commands` as `_` and dropped it, so the `/` menu stayed empty for claude/codex until a manual refetch — the same late-catalog parity regression class as the model/mode/effort pickers. Emit `AgentStreamEvent::AvailableCommands` alongside the config-option frame on `CatalogUpdated`, guarded so an empty list never sends a spurious frame that would clobber a REST-loaded menu. --- crates/aionui-ai-agent/src/session_agent.rs | 94 ++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index 14c730e41..69ced17ef 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -24,6 +24,7 @@ use crate::agent_task::IAgentTask; use crate::error::AgentError; use crate::protocol::events::session_updates::ThinkingEventData; use crate::protocol::events::tool_call::{ToolCallEventData, ToolCallStatus}; +use crate::protocol::events::session_updates::AvailableCommandsEventData; use crate::protocol::events::{ AgentStreamEvent, FinishEventData, StartEventData, TextEventData, TipType, TipsEventData, }; @@ -1508,7 +1509,7 @@ fn spawn_event_pump( if let SessionEvent::CatalogUpdated { models, modes, - slash_commands: _, + slash_commands, } = &env.event { let mut config_options: Vec = Vec::new(); @@ -1588,6 +1589,28 @@ fn spawn_event_pump( { 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; } @@ -3663,6 +3686,75 @@ mod pump_tests { ); } + // 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 backend: Arc = Arc::new(ScriptBackend(script)); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let frames = drain(&task).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 backend: Arc = Arc::new(ScriptBackend(script)); + let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); + let frames = drain(&task).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)] From 35641eed940ac87fb3b2e42d31a6d3ca5eecdba0 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Fri, 10 Jul 2026 16:55:20 +0800 Subject: [PATCH 14/22] fix(session): wire codex reasoning-effort setter via thread/settings/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting reasoning effort for a codex conversation 400'd with "command not supported by this backend: set_config_option": codex_conn's dispatch rejected every Command::SetConfigOption unconditionally, on the premise (stale comment) that codex has no standalone effort wire and effort can only ride SetMode's collaborationMode. The generated schema contradicts that — ThreadSettingsUpdate- Params exposes a first-class `effort` field ("Override the reasoning effort for subsequent turns" -> ReasoningEffort), and codex_conn already speaks that exact wire for SetModel/SetMode. Route the effort option ids (effort/reasoning_effort/thought_level) through thread/settings/update{threadId, effort}, mirroring SetModel: register the rpc id so a rejection surfaces as a Notice, pass the value verbatim (it is one of the model's advertised supportedReasoningEfforts; codex validates on the response). Any other generic config option still rejects. This makes codex symmetric with claude so the effort picker's set path succeeds. verified: samples/codex-cli/0.137.0/schema-full/ClientRequest.json (ThreadSettingsUpdateParams.effort, ReasoningEffort enum) Also fixes a pre-existing newer-clippy single_match lint in a claude_conn test (unrelated to the change but in the same crate, blocking its clippy gate). --- .../aionui-session/src/backend/claude_conn.rs | 22 ++--- .../aionui-session/src/backend/codex_conn.rs | 98 +++++++++++++++++-- 2 files changed, 101 insertions(+), 19 deletions(-) diff --git a/crates/aionui-session/src/backend/claude_conn.rs b/crates/aionui-session/src/backend/claude_conn.rs index f87f4ed0a..ba0b088da 100644 --- a/crates/aionui-session/src/backend/claude_conn.rs +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -4583,19 +4583,15 @@ mod tests { let mut catalog = None; for _ in 0..40 { - match tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await { - Ok(Some(env)) => { - if let SessionEvent::CatalogUpdated { - models, - modes, - slash_commands, - } = env.event - { - catalog = Some((models, modes, slash_commands)); - break; - } - } - _ => {} + 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"); diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs index f6c8df3f7..2599d397f 100644 --- a/crates/aionui-session/src/backend/codex_conn.rs +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -1366,7 +1366,7 @@ async fn reader_task( tracing::error!( conversation_id = %session_id, set = %label, - "codex thread/settings/update (SetMode/SetModel) rejected by agent: {message}" + "codex thread/settings/update (SetMode/SetModel/effort) rejected by agent: {message}" ); emit( &event_tx, @@ -3100,11 +3100,45 @@ impl SessionBackend for CodexSessionBackend { turn_gen: self.turn_gen.load(Ordering::SeqCst), }) } - // #99: codex's effort is a collaborationMode reasoning_effort setting set - // via SetMode (thread/settings), not a generic config-option command — so - // the generic SetConfigOption has no codex wire and rejects (cap=false ↔ - // reject). (If a codex effort-via-SetConfigOption path is wired later, it - // routes through thread/settings like SetMode.) + // 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", }), @@ -4947,6 +4981,58 @@ mod tests { ); } + #[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 From e3775bc94f74ffdcd7ffa8c00a1a79561c728c9f Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Fri, 10 Jul 2026 19:01:15 +0800 Subject: [PATCH 15/22] feat(claude): surface ultracode as an effort level for xhigh-capable models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inject a synthetic "ultracode" reasoning-effort level into an xhigh-capable model's advertised efforts (mirroring the claude CLI's own effort-picker entry "ultracode: xhigh + dynamic workflow orchestration"), so it flows through both config-option picker builds and passes effort_is_supported like a real level. Unlike real levels it does NOT ride the effortLevel field: on dispatch it emits the dedicated boolean apply_flag_settings{settings:{ultracode:true}}, which the CLI auto-forces to xhigh. Sending effortLevel:"ultracode" would be rejected by our own effort_is_supported gate since ultracode is absent from the model's supportedEffortLevels catalog. Gated to xhigh-capable models only, matching the CLI gate (ultracode requires an xhigh-capable model + dynamic workflows). codex has a separate effort parse and never gains the level, so this is claude-scoped by construction. Wire LIVE-PROBED against claude-cli 2.1.206 (candidate a returns control_response{success}; get_settings.applied reads back {effort:"xhigh", ultracode:true}) — see protocols/samples/claude-cli/2.1.206/ultracode_wire.result.md. --- .../aionui-session/src/backend/claude_conn.rs | 147 +++++++++++++++++- 1 file changed, 141 insertions(+), 6 deletions(-) diff --git a/crates/aionui-session/src/backend/claude_conn.rs b/crates/aionui-session/src/backend/claude_conn.rs index ba0b088da..bf3aa10fc 100644 --- a/crates/aionui-session/src/backend/claude_conn.rs +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -1709,14 +1709,25 @@ fn sniff_control_initialize( .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: 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(), + reasoning_efforts, id, }) }) @@ -1878,6 +1889,23 @@ fn sniff_set_config_reject( 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` @@ -2355,10 +2383,19 @@ impl SessionBackend for ClaudeSessionBackend { "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": { "effortLevel": value }, + "settings": settings, })) .await?; // #99: register the minted ctl-id so the reader surfaces a REJECTION @@ -4110,6 +4147,104 @@ mod tests { 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, From caa7d1f979291df29a5700611d25fecbe7b01437 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Fri, 10 Jul 2026 19:06:25 +0800 Subject: [PATCH 16/22] style: apply rustfmt to session_agent/claude/codex_conn Mechanical rustfmt-only reflow (tuple + block-expression wrapping) on lines introduced by the recent effort/slash/codex-effort commits; no logic change. Restores a clean cargo fmt --all --check for the push gate. --- crates/aionui-ai-agent/src/session_agent.rs | 28 +++++-- crates/aionui-session/src/adapter/claude.rs | 18 ++++- .../aionui-session/src/backend/codex_conn.rs | 81 ++++++++++++++----- 3 files changed, 96 insertions(+), 31 deletions(-) diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index 69ced17ef..bba14efea 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -22,9 +22,9 @@ 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::session_updates::AvailableCommandsEventData; use crate::protocol::events::{ AgentStreamEvent, FinishEventData, StartEventData, TextEventData, TipType, TipsEventData, }; @@ -1609,7 +1609,9 @@ fn spawn_event_pump( .collect(); let _ = runtime .tx - .send(AgentStreamEvent::AvailableCommands(AvailableCommandsEventData { commands })); + .send(AgentStreamEvent::AvailableCommands(AvailableCommandsEventData { + commands, + })); } continue; } @@ -3289,10 +3291,17 @@ mod persist_tests { .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.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"); + 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 — @@ -3331,7 +3340,10 @@ mod persist_tests { 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")), + 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" ); } @@ -3355,7 +3367,11 @@ mod persist_tests { .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"); + assert_eq!( + effort.current_value.as_deref(), + Some("high"), + "the requested level is highlighted" + ); } // ── Defect 2: dead-resume-anchor self-heal ──────────────────────────── diff --git a/crates/aionui-session/src/adapter/claude.rs b/crates/aionui-session/src/adapter/claude.rs index a528db8f3..36429cdde 100644 --- a/crates/aionui-session/src/adapter/claude.rs +++ b/crates/aionui-session/src/adapter/claude.rs @@ -125,11 +125,23 @@ pub(crate) fn claude_permission_modes() -> Vec { // 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"), + ( + "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"), + ( + "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 { diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs index 2599d397f..a6f11a279 100644 --- a/crates/aionui-session/src/backend/codex_conn.rs +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -132,8 +132,9 @@ impl BackendConnection for CodexConnection { // not live-verified). Resume therefore keeps only the normalized persisted value. let normalized_config_mode = config.mode.as_deref().map(codex_perm::profile_id_to_legacy_value); backend.capabilities.current_mode = match &spec { - SessionSpec::Fresh { .. } => normalized_config_mode - .or_else(|| Some(codex_perm::profile_id_to_legacy_value(":workspace"))), + SessionSpec::Fresh { .. } => { + normalized_config_mode.or_else(|| Some(codex_perm::profile_id_to_legacy_value(":workspace"))) + } SessionSpec::Resume { .. } => normalized_config_mode, }; @@ -303,9 +304,13 @@ async fn reconcile_codex_mode(backend: &CodexSessionBackend, requested: String) // (`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; + 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!( @@ -2885,7 +2890,10 @@ impl SessionBackend for CodexSessionBackend { // 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}")); + 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": { @@ -5028,7 +5036,12 @@ mod tests { .await .expect_err("a non-effort config option must reject"); assert!( - matches!(err, BackendError::CommandNotSupported { command: "set_config_option" }), + matches!( + err, + BackendError::CommandNotSupported { + command: "set_config_option" + } + ), "got: {err:?}" ); } @@ -5731,7 +5744,8 @@ mod tests { 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 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; @@ -5745,14 +5759,16 @@ mod tests { // 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 + 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`"); + assert_eq!( + modes[0].id, "auto", + ":workspace surfaces as the legacy bare token `auto`" + ); saw_both = true; break; } @@ -5811,7 +5827,11 @@ mod tests { "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"); + 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 @@ -5819,10 +5839,15 @@ mod tests { // 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.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)"), + 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(); @@ -5854,7 +5879,13 @@ mod tests { } // 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"] { + 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. @@ -5870,10 +5901,7 @@ mod tests { 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" - ); + assert!(discovered.lock().unwrap().modes.is_empty(), "empty data[] → no modes"); } /// UT-3: a `permissionProfile/list` RESPONSE broadcasts a `CatalogUpdated` whose @@ -6014,7 +6042,11 @@ mod tests { 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); + backend + .pending_discovery + .lock() + .await + .insert(60, DiscoveryKind::Permissions); let _events = backend.events(); for _ in 0..40 { let filled = !backend @@ -6074,12 +6106,17 @@ mod tests { 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 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); + backend + .pending_discovery + .lock() + .await + .insert(61, DiscoveryKind::Permissions); let _events = backend.events(); for _ in 0..40 { let filled = !backend From 2dcf6d85079d3098e8c5e3858a21ae0b4083b067 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Mon, 13 Jul 2026 14:34:07 +0800 Subject: [PATCH 17/22] fix(session): close three session-port seam degradations (codex read-only first-turn write, dropped Notice, codex confirmation recovery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every silent capability loss at the session-port seam (backend supports X but the Session path drops it) is a bug. Three found and fixed; all reachable only via claude/codex (the sole backends routed to AgentInstance::Session). #7 codex read-only first-turn write (SECURITY). codex_sandbox_for_mode returned None for read-only, so thread/start seeded workspace-write and the FIRST turn could write; the post-open SetMode permission profile applies only to the NEXT turn. Now seed sandbox:"read-only" at thread/start so turn 1 is locked (SandboxMode verified codex-cli/0.137.0/schema-full/ClientRequest.json). sandbox and permissions ride separate wire calls, so the two axes compose. claude is unaffected: its mode is a spawn-time --permission-mode arg (fail-CLOSED), not a delayed reconcile. #2 backend Notice dropped at seam. Both backends emit SessionEvent::Notice specifically so a rejected mode/model/effort set or codex out-of-turn warning is visible; translate_event's catch-all re-dropped it. Now maps Notice → Tips (the one advisory frame the origin frontend renders), without touching turn state. #1 codex confirmation recovery. codex had no pending_permission_requests() override, so a tool/file/elicitation approval raised before subscribe or lost on reload could never be rebuilt and the turn hung. Added a transient pending_tool_approvals registry (insert on */requestApproval + elicitation, remove on serverRequest/resolved + AnswerPermission) and the override, keyed by the surfaced request_id so live+recovered de-dup. Titles carry the approval class only, never the command body (TIO-13). mod.rs trait doc corrected: ACP SessionBackend keeps such a registry but does not override (latent, unreachable). Tests: codex_sandbox_maps_full_access_and_read_only_modes, thread_start_sandbox_is_data_driven_from_config (read-only wire assert), notice_surfaces_as_tips, codex_pending_tool_approval_lists_open_then_clears_on_answer. aionui-ai-agent + aionui-session green, clippy -D clean, fmt clean. --- crates/aionui-ai-agent/src/session_agent.rs | 111 +++++++++-- .../aionui-session/src/backend/codex_conn.rs | 187 +++++++++++++++++- crates/aionui-session/src/backend/mod.rs | 12 +- 3 files changed, 283 insertions(+), 27 deletions(-) diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index bba14efea..769b32b79 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -1328,24 +1328,33 @@ fn catalog_partial_from_caps(caps: &aionui_session::Capabilities) -> Option) -> Option<&'static str> { match mode.map(str::trim) { Some(":danger-full-access" | "full-access" | "yoloNoSandbox") => Some("danger-full-access"), + Some(":read-only" | "read-only") => Some("read-only"), _ => None, } } @@ -2269,11 +2278,37 @@ fn translate_event(event: SessionEvent, conversation_id: &str, terminal_result_s }, )] } + // 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 / Notice / SubagentUpdate are also dropped for now — separate + // TurnDiffUpdated / SubagentUpdate are also dropped for now — separate // follow-ups (each needs its own origin frame + renderer verification). _ => Vec::new(), } @@ -2502,10 +2537,12 @@ mod build_mapping_tests { ); } - /// Only an explicit full-access / yolo mode escalates the codex sandbox; every - /// other mode (and None) keeps None ⇒ workspace-write. Ported verbatim. + /// 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_only_full_access_modes() { + fn codex_sandbox_maps_full_access_and_read_only_modes() { // Plan B canonical value: the legacy bare token. 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. @@ -2521,8 +2558,14 @@ mod build_mapping_tests { codex_sandbox_for_mode(Some(" :danger-full-access ")), Some("danger-full-access") ); - // read-only and workspace tiers do NOT escalate — the safe sandbox default applies. - assert_eq!(codex_sandbox_for_mode(Some(":read-only")), None); + // 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); @@ -2791,6 +2834,34 @@ mod translate_tests { ); } + // 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 diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs index a6f11a279..bfa19cd7f 100644 --- a/crates/aionui-session/src/backend/codex_conn.rs +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -32,7 +32,8 @@ use tokio::sync::{Mutex, broadcast}; use super::suspend::{ProcHandle, SuspendController, spawn_idle_timer}; use super::types::{ - Admission, BackendError, CancelTarget, Command, CommandReceipt, ContentBlock, SessionEnvelope, SessionSpec, + Admission, BackendError, CancelTarget, Command, CommandReceipt, ContentBlock, PendingPermissionView, + SessionEnvelope, SessionSpec, }; use super::{BackendConnection, SessionBackend, SessionConfig}; use crate::adapter::AgentIo; @@ -582,6 +583,18 @@ pub struct CodexSessionBackend { /// 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 @@ -669,6 +682,7 @@ struct CodexReaderState { thread_binding: Arc>>, active_turn_id: Arc>>, pending_auth_id: Arc>>, + pending_tool_approvals: Arc>>, pending_sends: Arc>>, pending_discovery: Arc>>, pending_set: Arc>>, @@ -698,6 +712,7 @@ fn start_codex_reader( state.thread_binding, state.active_turn_id, state.pending_auth_id, + state.pending_tool_approvals, state.pending_sends, state.pending_discovery, state.pending_set, @@ -813,6 +828,7 @@ impl CodexSessionBackend { 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())); @@ -834,6 +850,7 @@ impl CodexSessionBackend { 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(), @@ -886,6 +903,7 @@ impl CodexSessionBackend { thread_binding, active_turn_id, pending_auth_id, + pending_tool_approvals, current_model, pending_sends, pending_discovery, @@ -1067,6 +1085,7 @@ async fn reader_task( thread_binding: Arc>>, active_turn_id: Arc>>, pending_auth_id: Arc>>, + pending_tool_approvals: Arc>>, pending_sends: Arc>>, pending_discovery: Arc>>, pending_set: Arc>>, @@ -1139,8 +1158,17 @@ async fn reader_task( // 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, &stdin) - .await; + 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) @@ -1206,12 +1234,20 @@ async fn reader_task( 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: req_id.map(|v| v.to_string()).unwrap_or_default(), + request_id: resolved_id, kind, }, ); @@ -1763,6 +1799,18 @@ fn rollback_to_turn(result: &Value) -> 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: @@ -1774,6 +1822,7 @@ fn rollback_to_turn(result: &Value) -> u64 { /// 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, @@ -1781,6 +1830,7 @@ async fn handle_reverse_rpc( turn_gen: &Arc, event_tx: &broadcast::Sender, pending_auth_id: &Arc>>, + pending_tool_approvals: &Arc>>, stdin: &Arc>>, ) { let cur = turn_gen.load(Ordering::SeqCst); @@ -1839,6 +1889,22 @@ async fn handle_reverse_rpc( // 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, @@ -1876,6 +1942,13 @@ async fn handle_reverse_rpc( "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, @@ -3091,6 +3164,11 @@ impl SessionBackend for CodexSessionBackend { }) }; 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, @@ -3187,6 +3265,31 @@ impl SessionBackend for CodexSessionBackend { } 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 @@ -3537,6 +3640,69 @@ mod tests { } } + /// 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 @@ -5519,6 +5685,19 @@ mod tests { ); // 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 diff --git a/crates/aionui-session/src/backend/mod.rs b/crates/aionui-session/src/backend/mod.rs index 7ce829032..3d580685c 100644 --- a/crates/aionui-session/src/backend/mod.rs +++ b/crates/aionui-session/src/backend/mod.rs @@ -82,9 +82,15 @@ pub trait SessionBackend: Send + Sync { /// detail (transient, never persisted) was lost on the frontend reload. Reads /// the adapter's transient pending registry; NOT reducer/FSM/persisted. /// - /// Default empty: backends without a pending registry (codex's only pending is - /// auth, not a tool permission; ACP `session/request_permission` is - /// fire-and-forget) and every test double need no change. claude overrides it. + /// 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() } From 1d9e4b77c212d93746cb389457859a16b9180f62 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Mon, 13 Jul 2026 15:24:51 +0800 Subject: [PATCH 18/22] test(session): track aionui-session claude wire fixtures used by include_str! MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aionui-session lib tests embed captured claude CLI transcripts via include_str!("../../tests/fixtures/*.ndjson") (claude.rs, claude_conn.rs), but the tests/fixtures/ directory was never tracked on this branch — it landed on the phase-0 baseline commit that feat/session-port does not descend from. Local builds passed because the files exist on disk; CI failed to compile aionui-session (lib test) because the three referenced fixtures were absent from the checkout: couldn't read tests/fixtures/claude_2.1.169_single_tool_turn.ndjson couldn't read tests/fixtures/claude_2.1.176_workflow_multiagent_3parallel_1fail.ndjson couldn't read tests/fixtures/claude_2.1.185_cancel_before_output_result.ndjson Track all 11 fixtures (byte-identical to the phase-0 baseline), so the crate's tests compile from a clean checkout. Captured wire samples only; no code change. --- .../claude_2.1.169_single_tool_turn.ndjson | 15 ++++ ...workflow_multiagent_3parallel_1fail.ndjson | 69 +++++++++++++++++++ ...2.1.185_cancel_before_output_result.ndjson | 1 + .../tests/fixtures/claude_error_result.ndjson | 2 + .../claude_parallel_tools_turn.ndjson | 6 ++ .../claude_read_attachment_turn.ndjson | 5 ++ .../claude_read_image_tool_turn.ndjson | 5 ++ .../fixtures/claude_simple_text_turn.ndjson | 3 + .../claude_thinking_then_text_turn.ndjson | 4 ++ .../fixtures/claude_write_tool_turn.ndjson | 5 ++ .../tests/fixtures/trace-tool.ndjson | 6 ++ 11 files changed, 121 insertions(+) create mode 100644 crates/aionui-session/tests/fixtures/claude_2.1.169_single_tool_turn.ndjson create mode 100644 crates/aionui-session/tests/fixtures/claude_2.1.176_workflow_multiagent_3parallel_1fail.ndjson create mode 100644 crates/aionui-session/tests/fixtures/claude_2.1.185_cancel_before_output_result.ndjson create mode 100644 crates/aionui-session/tests/fixtures/claude_error_result.ndjson create mode 100644 crates/aionui-session/tests/fixtures/claude_parallel_tools_turn.ndjson create mode 100644 crates/aionui-session/tests/fixtures/claude_read_attachment_turn.ndjson create mode 100644 crates/aionui-session/tests/fixtures/claude_read_image_tool_turn.ndjson create mode 100644 crates/aionui-session/tests/fixtures/claude_simple_text_turn.ndjson create mode 100644 crates/aionui-session/tests/fixtures/claude_thinking_then_text_turn.ndjson create mode 100644 crates/aionui-session/tests/fixtures/claude_write_tool_turn.ndjson create mode 100644 crates/aionui-session/tests/fixtures/trace-tool.ndjson 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"} From 33097df416a835c8cb5da6d660b8608a8fe8f306 Mon Sep 17 00:00:00 2001 From: BlackKeyZ Date: Mon, 13 Jul 2026 17:26:15 +0800 Subject: [PATCH 19/22] test(session): fix flaky pump_tests subscribe-vs-pump race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SessionAgentTask::build` spawns the event pump at construction, so it starts polling the backend's `events()` stream immediately. `ScriptBackend` returned an already-ready `stream::iter`, so on a multi_thread runtime the pump could project — and drop — frames before the test's post-`new()` `subscribe()` ran (the broadcast channel keeps no pre-subscribe buffer). This flaked `catalog_updated_projects_available_commands_frame` in CI (single-event script = smallest race window) while passing locally. Fix is test-only: `GatedScriptBackend` parks its stream on a `Notify` until released, and `drain_script` subscribes BEFORE releasing the gate, so no frame can predate the subscribe. Mirrors production, where the backend event stream is subscribed at open but stays silent until the CLI emits (well after the frontend's WS subscribe). Confirmed deterministic: injecting a 200ms pre-subscribe delay (which fails the old structure) keeps all 17 pump_tests green. --- crates/aionui-ai-agent/src/session_agent.rs | 87 +++++++++++++++------ 1 file changed, 64 insertions(+), 23 deletions(-) diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index aacd14479..5cae362b9 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -3643,15 +3643,70 @@ mod pump_tests { } } - // Collect every frame the task forwards until its event stream drains. - async fn drain(task: &Arc) -> Vec { + /// 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 } @@ -3693,9 +3748,7 @@ mod pump_tests { outcome: aionui_session::TurnOutcome::EndTurn, }), ]; - let backend: Arc = Arc::new(ScriptBackend(script)); - let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); - let frames = drain(&task).await; + 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!( @@ -3747,9 +3800,7 @@ mod pump_tests { }], slash_commands: Vec::new(), })]; - let backend: Arc = Arc::new(ScriptBackend(script)); - let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); - let frames = drain(&task).await; + let frames = drain_script(script).await; let config = frames .iter() .find_map(|f| match f { @@ -3812,9 +3863,7 @@ mod pump_tests { }, ], })]; - let backend: Arc = Arc::new(ScriptBackend(script)); - let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); - let frames = drain(&task).await; + let frames = drain_script(script).await; let commands = frames .iter() .find_map(|f| match f { @@ -3846,9 +3895,7 @@ mod pump_tests { modes: Vec::new(), slash_commands: Vec::new(), })]; - let backend: Arc = Arc::new(ScriptBackend(script)); - let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); - let frames = drain(&task).await; + let frames = drain_script(script).await; assert!( !frames .iter() @@ -3949,9 +3996,7 @@ mod pump_tests { outcome: aionui_session::TurnOutcome::EndTurn, }), ]; - let backend: Arc = Arc::new(ScriptBackend(script)); - let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); - let frames = drain(&task).await; + 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. @@ -4016,9 +4061,7 @@ mod pump_tests { outcome: aionui_session::TurnOutcome::EndTurn, }), ]; - let backend: Arc = Arc::new(ScriptBackend(script)); - let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); - let frames = drain(&task).await; + 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 {:?}", @@ -4246,9 +4289,7 @@ mod pump_tests { text: "line-2\n".into(), }), ]; - let backend: Arc = Arc::new(ScriptBackend(script)); - let task = SessionAgentTask::new(AgentType::Acp, "conv-1".into(), "/w".into(), backend, None); - let frames = drain(&task).await; + let frames = drain_script(script).await; let outputs: Vec = frames .iter() .filter_map(|f| match f { From ecb7faa39b546f6ef2e898cf2aa63dade18bef21 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 14 Jul 2026 20:53:12 +0800 Subject: [PATCH 20/22] chore: apply auto-fixes (fmt + clippy) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 22181cbd8..c84766b3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -744,7 +744,7 @@ dependencies = [ [[package]] name = "aionui-process" -version = "0.1.46" +version = "0.1.47" dependencies = [ "aionui-common", "aionui-runtime", @@ -805,7 +805,7 @@ dependencies = [ [[package]] name = "aionui-session" -version = "0.1.46" +version = "0.1.47" dependencies = [ "aionui-common", "aionui-process", From ee018dea4cb704a898b126b51907ea12adbdeb8d Mon Sep 17 00:00:00 2001 From: blackkey Date: Thu, 16 Jul 2026 20:36:25 +0800 Subject: [PATCH 21/22] fix(session): close tool calls left open at turn end as canceled An interrupted turn (user cancel, process crash, or the CLI dropping the result) never delivers a terminal ToolResult for in-flight tool calls, so the persisted tool_call row stayed status "work" forever and the frontend View-Steps spinner (hasRunningToolMessages) never stopped, surviving reloads. - protocol: add ToolCallStatus::Canceled (wire "canceled") - session pump: track open tool calls per turn; on the terminal arm emit a Canceled ToolCall frame for each still-open call before the Finish (the relay stops forwarding a turn at Finish) - session pump: also populate the tool_name map, which was never inserted into, so ToolOutputDelta/ToolResult follow-up frames went out nameless - persistence: Canceled persists as terminal row status "finish"; persist_tool_group all_done now includes Canceled - tests: pump cancel/no-recancel ordering + relay Canceled persistence --- .../src/protocol/events/tool_call.rs | 4 + crates/aionui-ai-agent/src/session_agent.rs | 117 ++++++++++++++++++ .../src/stream_persistence.rs | 12 +- .../tests/stream_relay_tool_call.rs | 65 ++++++++++ 4 files changed, 195 insertions(+), 3 deletions(-) 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 index 5cae362b9..d5a90d2e5 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -1466,6 +1466,14 @@ fn spawn_event_pump( // 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 @@ -1679,8 +1687,39 @@ fn spawn_event_pump( // 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 { .. }) { @@ -3768,6 +3807,84 @@ mod pump_tests { ); } + // 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 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/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, From 5ae5b00b66e33936a9158cf02a6737f35a04fdc4 Mon Sep 17 00:00:00 2001 From: blackkey Date: Thu, 16 Jul 2026 21:33:00 +0800 Subject: [PATCH 22/22] fix(session): seed codex resume current_mode in catalog vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening an old codex conversation rendered the permission pill as "权限 · " with an empty mode label: the resume path feeds the #608 canonical id (agent-full-access, produced by normalize_requested_mode from the persisted mode) into the open_session seed, but the seed only applied profile_id_to_legacy_value, which translates colon ids and passes bare tokens through verbatim. The catalog only carries [read-only, auto, full-access], so the current value matched no option and the picker had nothing to highlight. Fresh sessions were unaffected (reconcile re-derives the mode); Resume never self-corrects by design. Add codex_perm::mode_to_catalog_value — the inbound normalization (normalize_to_profile_id) composed with the outbound leg (profile_id_to_legacy_value) — and use it for the seed, so every accepted vocabulary (canonical id, legacy aliases, colon ids, custom profiles) lands on the catalog value space. Unknown bare tokens bucket to the workspace tier exactly like the SetMode apply path, so the displayed tier cannot drift from the applied tier. --- .../aionui-session/src/backend/codex_conn.rs | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs index 2a4553f35..db00632aa 100644 --- a/crates/aionui-session/src/backend/codex_conn.rs +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -115,8 +115,15 @@ impl BackendConnection for CodexConnection { // 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): a persisted colon id (`:danger-full-access` from older data) - // is mapped to its bare form so the picker highlights the matching catalog entry. + // 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 @@ -131,7 +138,7 @@ impl BackendConnection for CodexConnection { // 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::profile_id_to_legacy_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"))) @@ -1570,6 +1577,23 @@ mod codex_perm { } } + /// 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: @@ -6077,6 +6101,37 @@ mod tests { 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]