From 0cd927edf309b9cd9e3df5291b8543b3c6b95734 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:50:04 +0800 Subject: [PATCH] feat(agents): Native Claude Code / Codex drivers, ACP v2 and the ACP Registry (#151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents can run through native drivers or any ACP agent, speak ACP v1 or v2, and be installed from the ACP Registry. Tasks gain fork, in-place edit-and-rerun, steering and usage-limit handling where the agent supports them. - Native Claude Code (`claude` stream-json) and Codex (`codex app-server`) drivers, each served to the task machinery as an in-process ACP shim, using the newest install on PATH. - ACP v2 with v1 fallback, negotiated on initialize; per-agent capabilities (fork, rollback, steer) so the UI only shows supported actions; usage-limit errors put the task in a `limited` status with the reset time. - Settings › Tools › Agents lists Registry agents (featured first) to install, update and remove; installed agents update when a project opens. "More agents…" in the provider picker opens it. Registry index and downloads are HTTPS-only, release archives are checked against their sha256, and entries without one show "No checksum provided" and ask for confirmation before installing. Package names from the registry can't be passed as options to npm/uv. Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA --- Cargo.lock | 3 + crates/ide-agent/Cargo.toml | 3 + crates/ide-agent/src/acp/e2e.rs | 214 +++ crates/ide-agent/src/acp/mod.rs | 425 ++++++ crates/ide-agent/src/acp/normalize.rs | 345 +++++ crates/ide-agent/src/acp/tests.rs | 529 +++++++ crates/ide-agent/src/conversation.rs | 13 + crates/ide-agent/src/lib.rs | 512 ++++++- crates/ide-agent/src/model.rs | 64 + crates/ide-agent/src/native/claude/cli.rs | 158 ++ crates/ide-agent/src/native/claude/history.rs | 226 +++ crates/ide-agent/src/native/claude/mod.rs | 1325 +++++++++++++++++ .../ide-agent/src/native/claude/settings.rs | 234 +++ crates/ide-agent/src/native/claude/tests.rs | 1003 +++++++++++++ crates/ide-agent/src/native/claude/tools.rs | 259 ++++ .../ide-agent/src/native/claude/translate.rs | 563 +++++++ crates/ide-agent/src/native/codex/mod.rs | 951 ++++++++++++ crates/ide-agent/src/native/codex/tests.rs | 872 +++++++++++ .../ide-agent/src/native/codex/translate.rs | 837 +++++++++++ crates/ide-agent/src/native/mod.rs | 96 ++ crates/ide-agent/src/provider.rs | 165 +- crates/ide-agent/src/registry.rs | 928 ++++++++++++ crates/ide-agent/src/replay.rs | 257 ++++ crates/ide-agent/src/replay_tests.rs | 222 +++ crates/ide-agent/src/tests.rs | 141 +- crates/ide-agent/src/timeline.rs | 9 +- .../tests/fixtures/acp-v2/cancel.jsonl | 18 + .../tests/fixtures/acp-v2/initialize-v1.jsonl | 2 + .../tests/fixtures/acp-v2/resume.jsonl | 21 + .../tests/fixtures/acp-v2/subagent.jsonl | 79 + .../tests/fixtures/acp-v2/turn.jsonl | 44 + .../tests/fixtures/acp/claude-acp/config.json | 1 + .../acp/claude-acp@v1/multi_turn.jsonl | 27 + .../acp/claude-acp@v1/permission_allow.jsonl | 32 + .../claude-acp@v1/permission_decline.jsonl | 28 + .../acp/claude-acp@v1/queued_turn.jsonl | 26 + .../fixtures/acp/claude-acp@v1/resume.jsonl | 41 + .../fixtures/acp/claude-acp@v1/simple.jsonl | 20 + .../acp/claude-acp@v1/turn_interrupt.jsonl | 10 + .../acp/claude-acp@v2/multi_turn.jsonl | 31 + .../acp/claude-acp@v2/permission_allow.jsonl | 37 + .../claude-acp@v2/permission_decline.jsonl | 33 + .../acp/claude-acp@v2/queued_turn.jsonl | 30 + .../fixtures/acp/claude-acp@v2/resume.jsonl | 45 + .../fixtures/acp/claude-acp@v2/simple.jsonl | 21 + .../acp/claude-acp@v2/turn_interrupt.jsonl | 9 + .../tests/fixtures/claude/interrupt.jsonl | 33 + .../tests/fixtures/claude/multi_turn.jsonl | 98 ++ .../tests/fixtures/claude/resume.jsonl | 45 + .../fixtures/claude/resume.transcript.jsonl | 29 + .../tests/fixtures/claude/simple.jsonl | 22 + .../tests/fixtures/claude/steer.jsonl | 112 ++ .../tests/fixtures/claude/subagent.jsonl | 92 ++ .../tests/fixtures/claude/tool_approval.jsonl | 216 +++ .../tests/fixtures/codex/approvals.jsonl | 30 + .../tests/fixtures/codex/auth_failure.jsonl | 41 + .../tests/fixtures/codex/interrupt.jsonl | 29 + .../tests/fixtures/codex/multi_turn.jsonl | 32 + .../tests/fixtures/codex/resume.jsonl | 28 + .../tests/fixtures/codex/simple.jsonl | 28 + .../tests/fixtures/codex/steer.jsonl | 31 + .../tests/fixtures/codex/subagent.jsonl | 29 + .../tests/fixtures/codex/usage_limit.jsonl | 18 + crates/ide-host/src/ops/agent.rs | 85 +- crates/ide-host/src/project.rs | 1 + crates/ide-jsonrpc/src/lib.rs | 16 +- crates/ide-tools/src/archive.rs | 16 +- crates/ide-tools/src/lib.rs | 3 +- crates/ide-tools/src/process.rs | 31 +- src-tauri/src/commands/agent.rs | 77 +- src-tauri/src/commands/mod.rs | 6 + src/bindings.ts | 122 +- src/features/agents/AgentRegistry.module.css | 133 ++ src/features/agents/AgentRegistryPage.tsx | 281 ++++ src/features/agents/AgentsView.tsx | 8 +- src/features/agents/HomeEditor.tsx | 50 +- src/features/agents/Session.module.css | 27 + src/features/agents/SessionEditor.tsx | 155 +- src/features/agents/agentRegistry.test.tsx | 260 ++++ src/features/agents/agents.test.ts | 26 + src/features/agents/composerCommands.test.tsx | 1 + src/features/agents/index.ts | 26 +- src/features/agents/model.ts | 18 +- src/features/agents/providerOptions.ts | 28 + src/features/agents/registry.ts | 91 ++ src/features/agents/store.ts | 49 +- src/features/index.ts | 3 +- src/platform/locales/text/zh.json | 28 +- 88 files changed, 13204 insertions(+), 159 deletions(-) create mode 100644 crates/ide-agent/src/acp/e2e.rs create mode 100644 crates/ide-agent/src/acp/mod.rs create mode 100644 crates/ide-agent/src/acp/normalize.rs create mode 100644 crates/ide-agent/src/acp/tests.rs create mode 100644 crates/ide-agent/src/native/claude/cli.rs create mode 100644 crates/ide-agent/src/native/claude/history.rs create mode 100644 crates/ide-agent/src/native/claude/mod.rs create mode 100644 crates/ide-agent/src/native/claude/settings.rs create mode 100644 crates/ide-agent/src/native/claude/tests.rs create mode 100644 crates/ide-agent/src/native/claude/tools.rs create mode 100644 crates/ide-agent/src/native/claude/translate.rs create mode 100644 crates/ide-agent/src/native/codex/mod.rs create mode 100644 crates/ide-agent/src/native/codex/tests.rs create mode 100644 crates/ide-agent/src/native/codex/translate.rs create mode 100644 crates/ide-agent/src/native/mod.rs create mode 100644 crates/ide-agent/src/registry.rs create mode 100644 crates/ide-agent/src/replay.rs create mode 100644 crates/ide-agent/src/replay_tests.rs create mode 100644 crates/ide-agent/tests/fixtures/acp-v2/cancel.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp-v2/initialize-v1.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp-v2/resume.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp-v2/subagent.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp-v2/turn.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp/config.json create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v1/multi_turn.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v1/permission_allow.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v1/permission_decline.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v1/queued_turn.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v1/resume.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v1/simple.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v1/turn_interrupt.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v2/multi_turn.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v2/permission_allow.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v2/permission_decline.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v2/queued_turn.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v2/resume.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v2/simple.jsonl create mode 100644 crates/ide-agent/tests/fixtures/acp/claude-acp@v2/turn_interrupt.jsonl create mode 100644 crates/ide-agent/tests/fixtures/claude/interrupt.jsonl create mode 100644 crates/ide-agent/tests/fixtures/claude/multi_turn.jsonl create mode 100644 crates/ide-agent/tests/fixtures/claude/resume.jsonl create mode 100644 crates/ide-agent/tests/fixtures/claude/resume.transcript.jsonl create mode 100644 crates/ide-agent/tests/fixtures/claude/simple.jsonl create mode 100644 crates/ide-agent/tests/fixtures/claude/steer.jsonl create mode 100644 crates/ide-agent/tests/fixtures/claude/subagent.jsonl create mode 100644 crates/ide-agent/tests/fixtures/claude/tool_approval.jsonl create mode 100644 crates/ide-agent/tests/fixtures/codex/approvals.jsonl create mode 100644 crates/ide-agent/tests/fixtures/codex/auth_failure.jsonl create mode 100644 crates/ide-agent/tests/fixtures/codex/interrupt.jsonl create mode 100644 crates/ide-agent/tests/fixtures/codex/multi_turn.jsonl create mode 100644 crates/ide-agent/tests/fixtures/codex/resume.jsonl create mode 100644 crates/ide-agent/tests/fixtures/codex/simple.jsonl create mode 100644 crates/ide-agent/tests/fixtures/codex/steer.jsonl create mode 100644 crates/ide-agent/tests/fixtures/codex/subagent.jsonl create mode 100644 crates/ide-agent/tests/fixtures/codex/usage_limit.jsonl create mode 100644 src/features/agents/AgentRegistry.module.css create mode 100644 src/features/agents/AgentRegistryPage.tsx create mode 100644 src/features/agents/agentRegistry.test.tsx create mode 100644 src/features/agents/providerOptions.ts create mode 100644 src/features/agents/registry.ts diff --git a/Cargo.lock b/Cargo.lock index cf655846a..84b76a1b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3976,8 +3976,10 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" name = "ide-agent" version = "0.1.22" dependencies = [ + "dunce", "ide-core", "ide-jsonrpc", + "ide-tools", "ide-vcs", "parking_lot", "serde", @@ -3988,6 +3990,7 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", "tracing", "uuid", + "which", ] [[package]] diff --git a/crates/ide-agent/Cargo.toml b/crates/ide-agent/Cargo.toml index 248f6007f..6e4a53fba 100644 --- a/crates/ide-agent/Cargo.toml +++ b/crates/ide-agent/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true ide-core.workspace = true ide-jsonrpc.workspace = true ide-vcs.workspace = true +ide-tools = { path = "../ide-tools" } serde.workspace = true serde_json.workspace = true specta.workspace = true @@ -16,7 +17,9 @@ tokio.workspace = true toml.workspace = true tracing.workspace = true parking_lot.workspace = true +dunce.workspace = true uuid.workspace = true +which = "8" [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-agent/src/acp/e2e.rs b/crates/ide-agent/src/acp/e2e.rs new file mode 100644 index 000000000..b35369510 --- /dev/null +++ b/crates/ide-agent/src/acp/e2e.rs @@ -0,0 +1,214 @@ +//! A task against an ACP v2 agent that plays back a turn captured from `claude-agent-acp`. + +use super::tests::{TURN, frames}; +use crate::*; +use ide_jsonrpc::{Framing, Handler, Peer, codes}; +use parking_lot::Mutex; +use serde_json::{Value, json}; +use std::path::Path; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +const SESSION: &str = "28846a4c-c3e8-4a45-a3d0-c19f10d19d1a"; + +#[derive(Default)] +struct FakeV2Agent { + peer: OnceLock, + calls: Mutex>, +} + +impl FakeV2Agent { + fn methods(&self) -> Vec { + self.calls.lock().iter().map(|(m, _)| m.clone()).collect() + } + + fn result(id: i64) -> Value { + frames(TURN, "in").into_iter().find(|m| m["id"] == id && m.get("result").is_some()).unwrap()["result"].clone() + } + + fn update(&self, update: Value) { + let peer = self.peer.get().unwrap(); + peer.notify("session/update", json!({ "sessionId": SESSION, "update": update })).unwrap(); + } +} + +impl Handler for FakeV2Agent { + fn on_notification(&self, method: &str, params: Value) { + self.calls.lock().push((method.to_owned(), params)); + } + + fn on_request(&self, method: &str, params: Value) -> Result { + self.calls.lock().push((method.to_owned(), params.clone())); + let peer = self.peer.get().unwrap().clone(); + match method { + "initialize" => Ok(Self::result(1)), + "session/new" => Ok(Self::result(2)), + "session/set_config_option" => Ok(Self::result(3)), + "session/resume" => { + // The state snapshot v2 sends before answering. + self.update(json!({ "sessionUpdate": "state_update", "state": "idle" })); + Ok(json!({ "configOptions": Self::result(2)["configOptions"] })) + } + "session/prompt" if params.pointer("/prompt/0/text") == Some(&json!("again")) => { + self.update(json!({ "sessionUpdate": "user_message", "messageId": "u2", "content": params["prompt"] })); + std::thread::spawn(move || { + let update = |u: Value| peer.notify("session/update", json!({ "sessionId": SESSION, "update": u })); + update(json!({ "sessionUpdate": "state_update", "state": "running" })).unwrap(); + update(json!({ "sessionUpdate": "agent_message_chunk", "messageId": "m2", + "content": { "type": "text", "text": "second turn" } })) + .unwrap(); + update(json!({ "sessionUpdate": "state_update", "state": "idle", "stopReason": "end_turn" })) + .unwrap(); + }); + Ok(json!({ "messageId": "u2" })) + } + "session/prompt" => { + // The captured turn: its echo, the answer, then the rest on its own. + let script = frames(TURN, "in"); + let answer = script.iter().position(|m| m["id"] == 4).unwrap(); + peer.notify("session/update", &script[answer - 1]["params"]).unwrap(); + let rest = script[answer + 1..].to_vec(); + std::thread::spawn(move || { + for m in rest { + match m["method"].as_str() { + Some("session/update") => peer.notify("session/update", &m["params"]).unwrap(), + Some(method) => { + let _: Value = tokio_block(peer.request(method, &m["params"], None)).unwrap(); + } + None => {} + } + } + }); + Ok(Self::result(4)) + } + _ => Err((codes::METHOD_NOT_FOUND, method.to_owned())), + } + } +} + +fn tokio_block(f: F) -> F::Output { + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(f) +} + +fn manager(dir: &Path, agent: Arc) -> AgentManager { + let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| { + let (a, b) = tokio::io::duplex(1 << 20); + let (ar, aw) = tokio::io::split(a); + let (br, bw) = tokio::io::split(b); + let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines); + let _ = agent.peer.set(server); + Ok(Peer::connect_framed(ar, aw, client, Framing::Lines)) + }); + AgentManager::with_connector( + AgentPaths { + project_root: dir.to_path_buf(), + store_dir: dir.join("store"), + worktrees_dir: dir.join("worktrees"), + models_file: dir.join("models.json"), + custom_agents_dir: None, + home_dir: None, + }, + tokio::runtime::Handle::current(), + connector, + ) +} + +async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) { + for _ in 0..500 { + if mgr.task(id).unwrap().state.lock().task.status == status { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("task never reached {status:?}"); +} + +fn text(t: &str) -> AgentPrompt { + AgentPrompt { text: t.into(), ..AgentPrompt::default() } +} + +fn summary(entries: &[AgentEntry]) -> Vec { + entries + .iter() + .map(|e| match e { + AgentEntry::User { text, .. } => format!("user: {}", text.lines().next().unwrap_or_default()), + AgentEntry::Agent { text } => format!("agent: {text}"), + AgentEntry::Tool { title, status, diffs, .. } => format!("{title} {status:?} {}", diffs.len()), + AgentEntry::Permission { title, chosen, .. } => format!("{title}? {}", chosen.clone().unwrap_or_default()), + other => format!("{other:?}"), + }) + .collect() +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_task_runs_on_a_v2_agent_and_resumes_its_session() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeV2Agent::default()); + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + prompt: text("Use the TodoWrite tool"), + provider: "claude-acp".into(), + config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + // The turn is not over while the agent waits for an answer. + assert!(mgr.task(&task.id).unwrap().state.lock().busy); + let permission = mgr + .entries(&task.id, None) + .unwrap() + .iter() + .find_map(|e| match e { + AgentEntry::Permission { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + mgr.respond(&task.id, permission, Some("allow-once".into())).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + + let entries = mgr.entries(&task.id, None).unwrap(); + assert_eq!( + summary(&entries), + [ + "user: Use the TodoWrite tool", + "ToolSearch Completed 0", + "echo hello; ls -la /work/proj/note.txt 2>&1 Failed 0", + "Write /work/proj/note.txt Completed 1", + "Write /work/proj/note.txt? Yes", + "agent: done", + ] + ); + { + let t = mgr.task(&task.id).unwrap().state.lock().task.clone(); + assert_eq!(t.model.as_deref(), Some("haiku")); + assert_eq!(t.title, "Todo list and file creation test"); + assert!(t.usage.is_some()); + assert_eq!(t.commands.len(), 2, "{:?}", t.commands); + let mode = t.config.iter().find(|o| o.id == "mode").unwrap(); + assert_eq!(mode.via, AgentConfigVia::Option, "v2 has no modes: the mode is a config option"); + } + let set = agent.calls.lock().iter().find(|(m, _)| m == "session/set_config_option").unwrap().1.clone(); + assert_eq!((set["configId"].clone(), set["type"].clone()), (json!("model"), json!("id"))); + + // Restarted: the session resumes without a replay, and a stale `idle` doesn't end the next turn. + mgr.shutdown(); + let agent2 = Arc::new(FakeV2Agent::default()); + let mgr2 = manager(tmp.path(), agent2.clone()); + mgr2.prompt(&task.id, text("again"), false).await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + wait_for(&mgr2, &task.id, AgentStatus::Ready).await; + let entries = mgr2.entries(&task.id, None).unwrap(); + assert_eq!(summary(&entries[6..]), ["user: again", "agent: second turn"]); + let methods = agent2.methods(); + assert!( + methods.contains(&"session/resume".to_owned()) && !methods.contains(&"session/load".to_owned()), + "{methods:?}" + ); + let resume = agent2.calls.lock().iter().find(|(m, _)| m == "session/resume").unwrap().1.clone(); + assert_eq!(resume["sessionId"], SESSION); + assert!(resume.get("replayFrom").is_none()); +} diff --git a/crates/ide-agent/src/acp/mod.rs b/crates/ide-agent/src/acp/mod.rs new file mode 100644 index 000000000..46b4143d2 --- /dev/null +++ b/crates/ide-agent/src/acp/mod.rs @@ -0,0 +1,425 @@ +//! The ACP dialect an agent speaks: v1, or the v2 draft (`claude-agent-acp` serves v2 +//! to clients that ask for it). The rest of the crate speaks v1; this layer negotiates +//! the version in `initialize` and translates v2 in both directions: +//! +//! - `initialize`: one request that both versions accept ([`initialize_params`]); the +//! response's shape tells which one the agent chose ([`Negotiated`]). +//! - Requests ([`Conn::request`]): `session/load` becomes `session/resume` without a +//! replay (the task keeps its own history), `authenticate` becomes `auth/login`, a +//! select config value gets its `type: "id"` tag, and the commands that v2 puts in +//! setup responses arrive as the v1 `available_commands_update`. +//! - Turns: a v2 `session/prompt` answers once the prompt is taken in, and the turn ends +//! with an `idle` `state_update`; [`Conn::request`] resolves `session/prompt` there, +//! with the v1 response (`stopReason`) or the turn's error. +//! - Agent → client ([`Dialect`]): `session/update`s are normalized ([`normalize`]) and +//! permission requests get the v1 `toolCall`. + +mod normalize; + +use ide_jsonrpc::{Handler, Peer, RpcError, codes}; +use normalize::Normalizer; +use parking_lot::Mutex; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; +use tokio::sync::oneshot; + +/// The capabilities this client offers, in v1 form. +fn client_capabilities() -> Value { + json!({ + "fs": { "readTextFile": false, "writeTextFile": false }, + "terminal": false, + // Subagents as sessions of their own (ACP subagents RFD; v2 baseline). + "subagents": {}, + }) +} + +/// The `initialize` request for either version: v2's `protocolVersion`, `info` and +/// `capabilities`, plus v1's `clientCapabilities` and `clientInfo`. A v1 agent answers +/// with version 1 and reads the v1 fields; a v2 agent ignores them. +/// `client_info` is `{ name, version }`. +pub(crate) fn initialize_params(client_info: &Value) -> Value { + json!({ + "protocolVersion": 2, + "info": client_info, + "capabilities": {}, + "clientCapabilities": client_capabilities(), + "clientInfo": client_info, + }) +} + +/// What `initialize` settled: the protocol generation and the agent's capabilities in +/// v1 form (`agentCapabilities`), whichever version it speaks. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Negotiated { + /// 1 or 2. + pub generation: u8, + /// v1 `agentCapabilities`: `loadSession`, `promptCapabilities`, `mcpCapabilities`, + /// `sessionCapabilities`, `_meta`. + pub capabilities: Value, + /// v1 `agentInfo`. + pub agent_info: Option, + /// v1 `authMethods` (`{ id, name, description?, type? }`). + pub auth_methods: Vec, + /// The response's top-level `_meta`. + pub meta: Option, +} + +impl Negotiated { + /// Reads an `initialize` response. The shape decides, not `protocolVersion`: some v1 + /// agents report version 2 with v1 fields (`agentInfo`, `agentCapabilities`); only a + /// v2 response has `info`. + pub fn from_initialize(response: &Value) -> Self { + let meta = response.get("_meta").filter(|m| m.is_object()).cloned(); + if response.get("info").is_none_or(Value::is_null) { + return Self { + generation: 1, + capabilities: response.get("agentCapabilities").cloned().unwrap_or_else(|| json!({})), + agent_info: response.get("agentInfo").filter(|v| !v.is_null()).cloned(), + auth_methods: response.get("authMethods").and_then(Value::as_array).cloned().unwrap_or_default(), + meta, + }; + } + let caps = response.get("capabilities"); + let session = caps.and_then(|c| c.get("session")).filter(|s| s.is_object()); + // v2 capabilities are objects: present (`{}`) means supported, absent or `null` not. + let has = |v: Option<&Value>, key: &str| v.and_then(|v| v.get(key)).is_some_and(|c| !c.is_null()); + let prompt = session.and_then(|s| s.get("prompt")); + let mcp = session.and_then(|s| s.get("mcp")); + let mut capabilities = json!({ + // The session baseline includes `session/resume`, which stands in for `session/load`. + "loadSession": session.is_some(), + "promptCapabilities": { + "image": has(prompt, "image"), + "audio": has(prompt, "audio"), + "embeddedContext": has(prompt, "embeddedContext"), + }, + "mcpCapabilities": { "http": has(mcp, "http"), "sse": false }, + }); + if session.is_some() { + let mut sc = json!({ "list": {}, "resume": {}, "close": {} }); + for key in ["fork", "delete", "additionalDirectories"] { + if has(session, key) { + sc[key] = json!({}); + } + } + capabilities["sessionCapabilities"] = sc; + } + if let Some(m) = caps.and_then(|c| c.get("_meta")).filter(|m| m.is_object()) { + capabilities["_meta"] = m.clone(); + } + let auth_methods = response + .get("authMethods") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|m| { + let mut v1 = m.as_object()?.clone(); + let id = v1.remove("methodId")?; + v1.insert("id".into(), id); + Some(Value::Object(v1)) + }) + .collect(); + Self { generation: 2, capabilities, agent_info: response.get("info").cloned(), auth_methods, meta } + } + + /// Whether a saved session can be reopened (`session/load` on v1, `session/resume` on v2). + pub fn load_session(&self) -> bool { + self.capabilities.get("loadSession").and_then(Value::as_bool) == Some(true) + } + + /// `promptCapabilities.` (`image`, `audio`, `embeddedContext`). + pub fn prompt(&self, name: &str) -> bool { + self.capabilities.pointer(&format!("/promptCapabilities/{name}")).and_then(Value::as_bool) == Some(true) + } + + /// `sessionCapabilities.` (`fork`, `resume`, `close`, `list`, `delete`). + pub fn session(&self, name: &str) -> bool { + self.capabilities.pointer(&format!("/sessionCapabilities/{name}")).is_some_and(|v| !v.is_null()) + } + + /// `agentCapabilities._meta` at `pointer` (e.g. `/openidea/rollback`). + pub fn meta(&self, pointer: &str) -> Option<&Value> { + self.capabilities.get("_meta")?.pointer(pointer) + } +} + +/// The client side of one agent connection: wraps the task's [`Handler`] and, once +/// `initialize` has chosen v2, translates what the agent sends. +pub(crate) struct Dialect { + inner: Arc, + /// 0 until `initialize` answers; then 1 or 2. + generation: AtomicU8, + normalizer: Mutex, + /// Running v2 prompts, by session id. + turns: Mutex>, +} + +struct Turn { + done: oneshot::Sender>, + /// The agent took the prompt in (`user_message`) or reported the turn working. + /// Before that an `idle` without a stop reason is a stale state snapshot. + started: bool, +} + +impl Dialect { + pub fn wrap(inner: Arc) -> Arc { + Arc::new(Self { + inner, + generation: AtomicU8::new(0), + normalizer: Mutex::new(Normalizer::default()), + turns: Mutex::new(HashMap::new()), + }) + } + + pub fn generation(&self) -> u8 { + self.generation.load(Ordering::Acquire) + } + + fn v2(&self) -> bool { + self.generation() == 2 + } + + /// Feeds a v1 update to the wrapped handler as if the agent had sent it. + fn deliver(&self, session: &str, update: Value) { + self.inner.on_notification("session/update", json!({ "sessionId": session, "update": update })); + } + + /// Settles the running prompt of `session` on a v2 `state_update`. + fn on_state(&self, session: &str, update: &Value) { + let mut turns = self.turns.lock(); + let Some(turn) = turns.get_mut(session) else { return }; + let stop = update.get("stopReason").and_then(Value::as_str); + match update.get("state").and_then(Value::as_str) { + Some("idle") if turn.started || stop.is_some() => {} + Some("idle") => return, + Some(_) => { + turn.started = true; + return; + } + None => return, + } + let Some(turn) = turns.remove(session) else { return }; + let error = update.get("error").filter(|e| e.is_object()); + let result = match error { + e if e.is_some() || stop == Some("error") => Err(RpcError::Server { + code: e.and_then(|e| e.get("code")).and_then(Value::as_i64).unwrap_or(codes::INTERNAL_ERROR), + message: e + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .unwrap_or("The agent stopped with an error.") + .to_owned(), + data: e.and_then(|e| e.get("data")).cloned(), + }), + _ => { + let mut v = json!({ "stopReason": stop.unwrap_or("end_turn") }); + if let Some(u) = update.get("usage").filter(|u| !u.is_null()) { + v["usage"] = u.clone(); + } + Ok(v) + } + }; + let _ = turn.done.send(result); + } + + /// A v2 setup response (`session/new`, `resume`, `fork`): its commands become the + /// `available_commands_update` a v1 agent sends after it. + fn on_setup(&self, session: Option<&str>, response: &Value) { + let (Some(session), Some(commands)) = (session, response.get("availableCommands").filter(|c| c.is_array())) + else { + return; + }; + let update = json!({ "sessionUpdate": "available_commands_update", "availableCommands": commands }); + let updates = self.normalizer.lock().normalize(session, &update); + for u in updates { + self.deliver(session, u); + } + } +} + +impl Handler for Dialect { + fn on_notification(&self, method: &str, params: Value) { + if !self.v2() || method != "session/update" { + return self.inner.on_notification(method, params); + } + let (Some(session), Some(update)) = (params.get("sessionId").and_then(Value::as_str), params.get("update")) + else { + return; + }; + match update.get("sessionUpdate").and_then(Value::as_str) { + Some("state_update") => return self.on_state(session, update), + Some("user_message") => { + if let Some(turn) = self.turns.lock().get_mut(session) { + turn.started = true; + } + } + _ => {} + } + let updates = self.normalizer.lock().normalize(session, update); + for u in updates { + let mut p = params.clone(); + p["update"] = u; + self.inner.on_notification(method, p); + } + } + + fn on_request(&self, method: &str, params: Value) -> Result { + if self.v2() && method == "session/request_permission" { + return self.inner.on_request(method, v1_permission_request(params)); + } + self.inner.on_request(method, params) + } +} + +/// A v2 permission request with the v1 `toolCall` (its subject's, titled with the request's title). +fn v1_permission_request(mut params: Value) -> Value { + let subject = params.get("subject").cloned().unwrap_or(Value::Null); + let mut tool = match subject.get("type").and_then(Value::as_str) { + Some("tool_call") => subject.get("toolCall").cloned().unwrap_or_else(|| json!({})), + _ => json!({ "toolCallId": subject.get("toolCallId").cloned().unwrap_or(Value::Null), "kind": "execute" }), + }; + if let Some(content) = tool.get("content").and_then(Value::as_array) { + tool["content"] = Value::Array(normalize::v1_content(content, &|_| None)); + } + if let Some(title) = params.get("title").filter(|t| t.as_str().is_some_and(|t| !t.is_empty())) { + tool["title"] = title.clone(); + } + params["toolCall"] = tool; + params +} + +/// A connection to an agent, spoken in v1 whichever version the agent chose. +#[derive(Clone)] +pub(crate) struct Conn { + peer: Peer, + dialect: Arc, +} + +impl std::ops::Deref for Conn { + type Target = Peer; + fn deref(&self) -> &Peer { + &self.peer + } +} + +impl Conn { + /// `peer` must deliver the agent's messages to `dialect`. + pub fn new(peer: Peer, dialect: Arc) -> Self { + Self { peer, dialect } + } + + #[allow(dead_code)] + pub fn generation(&self) -> u8 { + self.dialect.generation() + } + + /// Sends the negotiating `initialize` and settles the dialect. + pub async fn initialize(&self, client_info: &Value) -> Result { + let response: Value = self.peer.request("initialize", initialize_params(client_info), None).await?; + let negotiated = Negotiated::from_initialize(&response); + self.dialect.generation.store(negotiated.generation, Ordering::Release); + Ok(negotiated) + } + + /// A v1 request, sent in the agent's dialect; the response comes back in v1 form. + pub async fn request( + &self, + method: &str, + params: P, + cancel: Option, + ) -> Result { + let params = serde_json::to_value(params).map_err(|e| RpcError::Protocol(e.to_string()))?; + let value = if self.dialect.v2() { + self.request_v2(method, params, cancel).await? + } else { + self.peer.request(method, params, cancel).await? + }; + serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}"))) + } + + async fn request_v2( + &self, + method: &str, + mut params: Value, + cancel: Option, + ) -> Result { + let session = params.get("sessionId").and_then(Value::as_str).map(str::to_owned); + match method { + "session/prompt" => return self.prompt_v2(params, cancel).await, + // The task keeps its history: resumed without a replay. + "session/load" | "session/resume" | "session/fork" => { + let method = if method == "session/load" { "session/resume" } else { method }; + let response: Value = self.peer.request(method, params, cancel).await?; + let id = response.get("sessionId").and_then(Value::as_str).map(str::to_owned).or(session); + self.dialect.on_setup(id.as_deref(), &response); + return Ok(response); + } + "session/new" => { + let response: Value = self.peer.request(method, params, cancel).await?; + self.dialect.on_setup(response.get("sessionId").and_then(Value::as_str), &response); + return Ok(response); + } + // v2 tags the value's type. + "session/set_config_option" if params.get("type").is_none() => { + params["type"] = + json!(if params.get("value").is_some_and(Value::is_boolean) { "boolean" } else { "id" }); + } + _ => {} + } + let method = match method { + "authenticate" => "auth/login", + "logout" => "auth/logout", + m => m, + }; + self.peer.request(method, params, cancel).await + } + + /// A v2 prompt: answered once taken in; the turn ends with the session's `idle`. + async fn prompt_v2(&self, params: Value, cancel: Option) -> Result { + let session = params.get("sessionId").and_then(Value::as_str).unwrap_or_default().to_owned(); + let (done, ended) = oneshot::channel(); + // Before sending: the turn can end before the response is read. + self.dialect.turns.lock().insert(session.clone(), Turn { done, started: false }); + let turns = &self.dialect.turns; + let _forget = Forget(|| { + turns.lock().remove(&session); + }); + match self.peer.request::<_, Value>("session/prompt", params, cancel.clone()).await { + Ok(_) => {} + // Cancelled while queued, before the agent took it in (`-32800`). + Err(RpcError::Cancelled) if !cancel.as_ref().is_some_and(|t| t.is_cancelled()) => { + return Ok(json!({ "stopReason": "cancelled" })); + } + Err(e) => return Err(e), + } + let cancelled = async { + match &cancel { + Some(t) => t.cancelled().await, + None => std::future::pending().await, + } + }; + tokio::select! { + biased; + r = ended => r.unwrap_or(Err(RpcError::Closed)), + _ = self.peer.closed() => Err(RpcError::Closed), + _ = cancelled => Err(RpcError::Cancelled), + } + } +} + +/// Runs its closure when dropped. +struct Forget(F); + +impl Drop for Forget { + fn drop(&mut self) { + (self.0)(); + } +} + +#[cfg(test)] +mod e2e; +#[cfg(test)] +mod tests; diff --git a/crates/ide-agent/src/acp/normalize.rs b/crates/ide-agent/src/acp/normalize.rs new file mode 100644 index 000000000..a4d81fcc7 --- /dev/null +++ b/crates/ide-agent/src/acp/normalize.rs @@ -0,0 +1,345 @@ +//! ACP v2 `session/update`s as the v1 updates the timeline folds. +//! +//! v2 reports tool calls as upserting `tool_call_update`s (plus appended +//! `tool_call_content_chunk`s), command output on display terminals, whole +//! messages next to their chunks, plans by id, and compaction as an entity of its +//! own. The normalizer keeps what it needs per session to turn each into zero or +//! more v1 updates: the tool calls it has announced and their content, the text +//! of each message so far, and each terminal's output. + +use serde_json::{Map, Value, json}; +use std::collections::{BTreeSet, HashMap}; + +/// Terminal output kept per terminal; longer output keeps its tail. +const MAX_TERMINAL: usize = 64 * 1024; + +#[derive(Default)] +pub(crate) struct Normalizer { + sessions: HashMap, +} + +#[derive(Default)] +struct SessionState { + /// v2 content of each tool call announced so far. + tools: HashMap>, + /// Text so far of each message, by `(chunk kind, messageId)`. + messages: HashMap<(String, String), String>, + terminals: HashMap, + /// The plan shown as the v1 `plan`. + plan: Option, + /// Summary so far of each compaction. + compactions: HashMap, +} + +#[derive(Default)] +struct Terminal { + output: String, + /// Tool calls whose content shows this terminal. + tools: BTreeSet, +} + +impl Normalizer { + /// The v1 updates for v2 update `update` of session `session`. + pub fn normalize(&mut self, session: &str, update: &Value) -> Vec { + let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default(); + let s = self.sessions.entry(session.to_owned()).or_default(); + match kind { + // The echo of a prompt: the client shows its own. + "user_message" => Vec::new(), + "agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk" => { + if let (Some(id), Some(text)) = (str_of(update, "messageId"), update.pointer("/content/text")) + && let Some(text) = text.as_str() + { + s.messages.entry((kind.to_owned(), id.to_owned())).or_default().push_str(text); + } + vec![update.clone()] + } + "agent_message" | "agent_thought" => { + let chunk = format!("{kind}_chunk"); + let id = str_of(update, "messageId").unwrap_or_default().to_owned(); + let text = message_text(update.get("content")); + let seen = s.messages.entry((chunk.clone(), id)).or_default(); + // A message replaces what its chunks said; the timeline only appends, so + // what the chunks did not say yet goes as one more chunk. + let new = match text.strip_prefix(seen.as_str()) { + Some(rest) => rest.to_owned(), + None => text.clone(), + }; + *seen = text; + if new.is_empty() { + return Vec::new(); + } + vec![json!({ "sessionUpdate": chunk, "content": { "type": "text", "text": new } })] + } + "tool_call_update" => { + let Some(id) = str_of(update, "toolCallId").map(str::to_owned) else { return Vec::new() }; + let first = !s.tools.contains_key(&id); + let content = s.tools.entry(id.clone()).or_default(); + let mut out = Map::new(); + for (k, v) in update.as_object().into_iter().flatten() { + // `null` clears a field in v2; the timeline keeps what it has. + if !v.is_null() && k != "content" { + out.insert(k.clone(), v.clone()); + } + } + let has_content = update.get("content").is_some(); + if has_content { + *content = update.get("content").and_then(Value::as_array).cloned().unwrap_or_default(); + } + if first { + out.insert("sessionUpdate".into(), json!("tool_call")); + if !out.contains_key("title") { + out.insert("title".into(), json!(str_of(update, "name").unwrap_or_default())); + } + } + if has_content || first { + s.link_terminals(&id); + out.insert("content".into(), Value::Array(s.v1_content(&id))); + } + vec![Value::Object(out)] + } + "tool_call_content_chunk" => { + let (Some(id), Some(item)) = (str_of(update, "toolCallId"), update.get("content")) else { + return Vec::new(); + }; + let first = !s.tools.contains_key(id); + s.tools.entry(id.to_owned()).or_default().push(item.clone()); + s.link_terminals(id); + let mut out = + json!({ "sessionUpdate": "tool_call_update", "toolCallId": id, "content": s.v1_content(id) }); + if first { + out["sessionUpdate"] = json!("tool_call"); + out["title"] = json!(""); + } + vec![out] + } + "terminal_update" | "terminal_output_chunk" => { + let Some(id) = str_of(update, "terminalId") else { return Vec::new() }; + let terminal = s.terminals.entry(id.to_owned()).or_default(); + let data = match kind { + "terminal_update" => update.pointer("/output/data"), + _ => update.get("data"), + }; + let Some(data) = data.and_then(Value::as_str) else { return Vec::new() }; + let text = String::from_utf8_lossy(&base64(data)).into_owned(); + if kind == "terminal_update" { + terminal.output = text; + } else { + terminal.output.push_str(&text); + } + tail(&mut terminal.output); + let tools: Vec = terminal.tools.iter().cloned().collect(); + tools + .into_iter() + .map(|t| json!({ "sessionUpdate": "tool_call_update", "toolCallId": t, "content": s.v1_content(&t) })) + .collect() + } + "plan_update" => { + let Some(plan) = update.get("plan") else { return Vec::new() }; + // Markdown and file plans have no v1 form; the tool call that made them shows them. + if plan.get("type").and_then(Value::as_str) != Some("items") { + return Vec::new(); + } + s.plan = str_of(plan, "planId").map(str::to_owned); + let entries = plan.get("entries").cloned().unwrap_or_else(|| json!([])); + vec![json!({ "sessionUpdate": "plan", "entries": entries })] + } + "plan_removed" => { + if s.plan.is_none() || s.plan.as_deref() != str_of(update, "planId") { + return Vec::new(); + } + s.plan = None; + vec![json!({ "sessionUpdate": "plan", "entries": [] })] + } + // The agent compacting its context, as v1 agents report it: a tool call. + "compaction_update" | "compaction_summary_chunk" => { + let Some(id) = str_of(update, "compactionId") else { return Vec::new() }; + let tool = format!("compaction:{id}"); + let first = !s.compactions.contains_key(id); + let summary = s.compactions.entry(id.to_owned()).or_default(); + let mut out = json!({ "sessionUpdate": "tool_call_update", "toolCallId": tool }); + if kind == "compaction_summary_chunk" { + if let Some(text) = update.pointer("/content/text").and_then(Value::as_str) { + summary.push_str(text); + } + } else { + if let Some(blocks) = update.get("summary").filter(|v| v.is_array()) { + *summary = message_text(Some(blocks)); + } + let status = match str_of(update, "status") { + Some("completed") => "completed", + Some("failed" | "cancelled") => "failed", + _ => "in_progress", + }; + out["status"] = json!(status); + if let Some(error) = str_of(update, "error") { + summary.push_str(error); + } + } + if !summary.is_empty() { + out["content"] = json!([{ "type": "content", "content": { "type": "text", "text": summary } }]); + } + if first { + out["sessionUpdate"] = json!("tool_call"); + out["title"] = json!("Compact conversation"); + out["kind"] = json!("other"); + } + vec![out] + } + // The turn's state: what ends a prompt (see `Dialect`), not part of the conversation. + "state_update" => Vec::new(), + // Same shape in both: config options (`configId`), commands (`input.type`), + // titles, usage, subagents, session messages, notices; and what v1 doesn't know. + _ => vec![update.clone()], + } + } +} + +impl SessionState { + /// Notes which terminals tool call `id` shows. + fn link_terminals(&mut self, id: &str) { + let content = self.tools.get(id).map(Vec::as_slice).unwrap_or_default(); + for item in content { + if item.get("type").and_then(Value::as_str) == Some("terminal") + && let Some(t) = str_of(item, "terminalId") + { + self.terminals.entry(t.to_owned()).or_default().tools.insert(id.to_owned()); + } + } + } + + /// Tool call `id`'s content in v1 form: a terminal as its output so far, a diff per changed file. + fn v1_content(&self, id: &str) -> Vec { + let content = self.tools.get(id).map(Vec::as_slice).unwrap_or_default(); + v1_content(content, &|t| self.terminals.get(t).map(|t| t.output.as_str())) + } +} + +/// v2 tool call content in v1 form. `terminal` gives a terminal's output so far. +pub(crate) fn v1_content<'a>(content: &[Value], terminal: &dyn Fn(&str) -> Option<&'a str>) -> Vec { + let mut out = Vec::new(); + for item in content { + match item.get("type").and_then(Value::as_str) { + Some("terminal") => { + if let Some(text) = str_of(item, "terminalId").and_then(terminal).filter(|t| !t.is_empty()) { + out.push(json!({ "type": "content", "content": { "type": "text", "text": text } })); + } + } + Some("diff") if item.get("changes").is_some() => out.extend(v1_diffs(item)), + _ => out.push(item.clone()), + } + } + out +} + +/// A v2 diff (`changes` and an optional git patch) as v1 diffs, one per changed file. v1 +/// diffs carry texts: each is the patch's hunks for the file, context plus removed lines +/// before and context plus added lines after, so diffing them counts the patch's lines. +fn v1_diffs(diff: &Value) -> Vec { + let patch = diff.pointer("/patch/text").and_then(Value::as_str).unwrap_or_default(); + let files = patch_files(patch); + diff.get("changes") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|change| { + let path = str_of(change, "path")?; + // Patch paths can be relative to the repository. + let same = |p: &str| !p.is_empty() && (p == path || path.ends_with(&format!("/{p}"))); + let (old, new) = + files.iter().find(|f| same(&f.0)).map(|f| (f.1.as_str(), f.2.as_str())).unwrap_or_default(); + Some(json!({ "type": "diff", "path": path, "oldText": old, "newText": new })) + }) + .collect() +} + +/// `(path, old text, new text)` of each file in a git patch, from its hunks. +fn patch_files(patch: &str) -> Vec<(String, String, String)> { + let mut files: Vec<(String, String, String)> = Vec::new(); + let mut in_hunk = false; + for line in patch.lines() { + if line.starts_with("diff --git ") { + files.push((String::new(), String::new(), String::new())); + in_hunk = false; + continue; + } + if !in_hunk && let Some(path) = line.strip_prefix("+++ ").or_else(|| line.strip_prefix("--- ")) { + let path = path.trim_end(); + let path = path.strip_prefix("b/").or_else(|| path.strip_prefix("a/")).unwrap_or(path); + if files.is_empty() { + files.push(Default::default()); + } + if path != "/dev/null" + && let Some(f) = files.last_mut() + { + f.0 = path.to_owned(); + } + continue; + } + if line.starts_with("@@") { + in_hunk = true; + continue; + } + let Some(f) = files.last_mut().filter(|_| in_hunk) else { continue }; + let (old, new) = (&mut f.1, &mut f.2); + match line.as_bytes().first() { + Some(b'-') => push_line(old, &line[1..]), + Some(b'+') => push_line(new, &line[1..]), + Some(b' ') => { + push_line(old, &line[1..]); + push_line(new, &line[1..]); + } + _ => {} + } + } + files +} + +fn push_line(text: &mut String, line: &str) { + text.push_str(line); + text.push('\n'); +} + +/// The text of a message's content blocks. +fn message_text(content: Option<&Value>) -> String { + let blocks = content.and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + blocks.iter().filter_map(|b| b.get("text").and_then(Value::as_str)).collect() +} + +fn str_of<'a>(v: &'a Value, key: &str) -> Option<&'a str> { + v.get(key).and_then(Value::as_str) +} + +fn tail(text: &mut String) { + if text.len() > MAX_TERMINAL { + let mut cut = text.len() - MAX_TERMINAL; + while !text.is_char_boundary(cut) { + cut += 1; + } + text.replace_range(..cut, ""); + } +} + +/// Standard base64 (padding optional); invalid characters are skipped. +pub(crate) fn base64(data: &str) -> Vec { + let mut out = Vec::with_capacity(data.len() * 3 / 4); + let (mut acc, mut bits) = (0u32, 0u32); + for b in data.bytes() { + let v = match b { + b'A'..=b'Z' => b - b'A', + b'a'..=b'z' => b - b'a' + 26, + b'0'..=b'9' => b - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + _ => continue, + }; + acc = (acc << 6) | u32::from(v); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + out +} diff --git a/crates/ide-agent/src/acp/tests.rs b/crates/ide-agent/src/acp/tests.rs new file mode 100644 index 000000000..3bdcf02d6 --- /dev/null +++ b/crates/ide-agent/src/acp/tests.rs @@ -0,0 +1,529 @@ +//! The dialect layer against frames captured from `claude-agent-acp` 0.97.0 +//! (`tests/fixtures/acp-v2/*.jsonl`: `{ dir, msg }` per line, `in` = agent → client). + +use super::normalize::{Normalizer, base64}; +use super::*; +use crate::conversation::Conversation; +use crate::model::{AgentEntry, AgentSubagentStatus, AgentToolDiff, AgentToolStatus}; +use crate::timeline::Timeline; +use ide_core::IpcPath; +use ide_jsonrpc::Framing; +use std::time::Duration; + +pub(crate) const TURN: &str = include_str!("../../tests/fixtures/acp-v2/turn.jsonl"); +const SUBAGENT: &str = include_str!("../../tests/fixtures/acp-v2/subagent.jsonl"); +const CANCEL: &str = include_str!("../../tests/fixtures/acp-v2/cancel.jsonl"); +const RESUME: &str = include_str!("../../tests/fixtures/acp-v2/resume.jsonl"); +const INITIALIZE_V1: &str = include_str!("../../tests/fixtures/acp-v2/initialize-v1.jsonl"); + +/// The messages of a fixture going `dir` (`in` or `out`). +pub(crate) fn frames(fixture: &str, dir: &str) -> Vec { + fixture + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str::(l).unwrap()) + .filter(|f| f["dir"] == dir) + .map(|f| f["msg"].clone()) + .collect() +} + +/// The result of request `id` in a fixture. +fn result(fixture: &str, id: i64) -> Value { + frames(fixture, "in").into_iter().find(|m| m["id"] == id && m.get("result").is_some()).unwrap()["result"].clone() +} + +/// The `(session id, update)` of each `session/update` the agent sent. +fn updates(fixture: &str) -> Vec<(String, Value)> { + frames(fixture, "in") + .into_iter() + .filter(|m| m["method"] == "session/update") + .map(|m| (m["params"]["sessionId"].as_str().unwrap().to_owned(), m["params"]["update"].clone())) + .collect() +} + +fn tool(entry: &AgentEntry) -> (&str, AgentToolStatus, Option<&str>) { + match entry { + AgentEntry::Tool { title, status, output, .. } => (title.as_str(), *status, output.as_deref()), + other => panic!("not a tool call: {other:?}"), + } +} + +#[test] +fn the_negotiating_request_carries_both_versions() { + let p = initialize_params(&json!({ "name": "OpenIDEA", "version": "1.0" })); + assert_eq!(p["protocolVersion"], 2); + assert_eq!(p["info"]["name"], "OpenIDEA"); + assert_eq!(p["clientInfo"]["name"], "OpenIDEA"); + assert!(p["capabilities"].is_object()); + assert_eq!(p["clientCapabilities"]["subagents"], json!({})); + assert_eq!(p["clientCapabilities"]["terminal"], false); + // What we sent in the capture is what claude-agent-acp negotiated v2 and v1 from. + let sent = &frames(TURN, "out")[0]["params"]; + assert_eq!(sent["clientCapabilities"], p["clientCapabilities"]); +} + +#[test] +fn a_v2_initialize_response_reads_as_v1_capabilities() { + let n = Negotiated::from_initialize(&result(TURN, 1)); + assert_eq!(n.generation, 2); + assert!(n.load_session()); + assert!(n.prompt("image") && n.prompt("embeddedContext") && !n.prompt("audio")); + assert!(n.session("fork") && n.session("resume") && n.session("close") && n.session("delete")); + assert_eq!(n.meta("/claudeCode/promptQueueing"), Some(&json!(true))); + assert_eq!(n.agent_info.as_ref().unwrap()["name"], "@agentclientprotocol/claude-agent-acp"); + assert_eq!(n.capabilities["mcpCapabilities"]["http"], true); +} + +#[test] +fn a_v1_agent_answers_the_same_request_in_v1() { + let response = result(INITIALIZE_V1, 1); + let n = Negotiated::from_initialize(&response); + assert_eq!(n.generation, 1); + assert_eq!(n.capabilities, response["agentCapabilities"]); + assert!(n.load_session() && n.prompt("image") && n.session("fork")); + assert_eq!(n.meta.as_ref().unwrap()["steering"]["supported"], true); +} + +#[test] +fn the_shape_decides_the_version_not_the_number() { + // Reports version 2 in v1 shape (as Antigravity does). + let n = Negotiated::from_initialize(&json!({ "protocolVersion": 2, "agentInfo": { "name": "x" }, + "agentCapabilities": { "loadSession": true, "promptCapabilities": { "image": true } } })); + assert_eq!(n.generation, 1); + assert!(n.load_session() && n.prompt("image")); + // A bare v1 response. + let n = Negotiated::from_initialize(&json!({ "protocolVersion": 1 })); + assert_eq!((n.generation, n.load_session(), n.prompt("image")), (1, false, false)); + // v2 without session capabilities: no resume. + let n = Negotiated::from_initialize(&json!({ "protocolVersion": 2, "info": { "name": "x", "version": "1" } })); + assert_eq!((n.generation, n.load_session(), n.session("fork")), (2, false, false)); +} + +#[test] +fn meta_and_auth_methods_pass_through() { + // The native shims advertise their extensions in `agentCapabilities._meta`. + let shim = Negotiated::from_initialize(&json!({ "protocolVersion": 1, "agentCapabilities": { + "loadSession": true, "sessionCapabilities": { "fork": {} }, + "_meta": { "openidea": { "rollback": true, "steer": true } } } })); + assert!(shim.meta("/openidea/rollback").is_some() && shim.meta("/openidea/steer").is_some()); + assert!(shim.session("fork")); + + let v2 = Negotiated::from_initialize(&json!({ "protocolVersion": 2, "info": { "name": "x", "version": "1" }, + "capabilities": { "session": { "fork": null, "prompt": { "image": null } }, "_meta": { "openidea": { "steer": true } } }, + "authMethods": [{ "type": "agent", "methodId": "login", "name": "Log in" }, + { "type": "terminal", "methodId": "cli", "name": "CLI", "args": ["login"] }] })); + assert!(v2.meta("/openidea/steer").is_some()); + // `null` is "not supported". + assert!(!v2.session("fork") && !v2.prompt("image")); + assert_eq!(v2.auth_methods[0], json!({ "type": "agent", "id": "login", "name": "Log in" })); + assert_eq!(v2.auth_methods[1]["id"], "cli"); + assert_eq!(v2.auth_methods[1]["args"], json!(["login"])); +} + +/// The captured turn (a Bash command on a display terminal, a Write with a git patch, a +/// permission request) folds into the same entries a v1 agent's turn does. +#[test] +fn a_captured_v2_turn_folds_into_the_timeline() { + let mut n = Normalizer::default(); + let mut t = Timeline::default(); + for (session, update) in updates(TURN) { + for u in n.normalize(&session, &update) { + assert_ne!(u["sessionUpdate"], "state_update"); + assert_ne!(u["sessionUpdate"], "terminal_update"); + t.apply(&u); + } + } + let e = &t.entries; + assert_eq!(e.len(), 4, "{e:#?}"); + assert_eq!(tool(&e[0]), ("ToolSearch", AgentToolStatus::Completed, Some("No matching deferred tools found"))); + let (title, status, output) = tool(&e[1]); + assert_eq!((title, status), ("echo hello; ls -la /work/proj/note.txt 2>&1", AgentToolStatus::Failed)); + assert!(output.unwrap().starts_with("Exit code 1\nhello\nls: /private/tmp/"), "{output:?}"); + let AgentEntry::Tool { title, status, diffs, kind, .. } = &e[2] else { panic!() }; + assert_eq!( + (title.as_str(), *status, kind.as_str()), + ("Write /work/proj/note.txt", AgentToolStatus::Completed, "edit") + ); + assert_eq!(diffs, &[AgentToolDiff { path: IpcPath("/work/proj/note.txt".into()), added: 1, removed: 0 }]); + assert_eq!(e[3], AgentEntry::Agent { text: "done".into() }); +} + +#[test] +fn a_captured_subagent_gets_its_card_and_timeline() { + let mut n = Normalizer::default(); + let mut c = Conversation::default(); + let root = updates(SUBAGENT)[0].0.clone(); + for (session, update) in updates(SUBAGENT) { + for u in n.normalize(&session, &update) { + c.apply(Some(&session), &u, 1.0); + } + } + let AgentEntry::Subagent { id, title, status, prompt, reply, tools, .. } = &c.main.entries[2] else { + panic!("{:#?}", c.main.entries) + }; + assert_eq!((title.as_deref(), *status, *tools), (Some("general-purpose"), AgentSubagentStatus::Done, 1)); + assert!(prompt.as_deref().unwrap().starts_with("List the files")); + assert!(reply.as_deref().unwrap().contains("note.txt")); + let child = c.timeline(&Some(id.clone())).unwrap(); + assert_eq!(tool(&child.entries[1]), ("ls", AgentToolStatus::Completed, Some("note.txt"))); + // The answer's chunks make one message. + let AgentEntry::Agent { text } = c.main.entries.last().unwrap() else { panic!() }; + assert!(text.starts_with("当前目录里只有一个文件") && text.ends_with("核。"), "{text}"); + assert_eq!(c.owner(Some(&root)), None); +} + +#[test] +fn a_replayed_history_clears_each_message_before_its_chunks() { + let mut n = Normalizer::default(); + let mut t = Timeline::default(); + let replay = updates(RESUME); + for (session, update) in &replay { + for u in n.normalize(session, update) { + t.apply(&u); + } + } + // The resume without a replay sent only its state snapshot; the replay, the history. + assert_eq!(replay[0].1, json!({ "sessionUpdate": "state_update", "state": "idle" })); + assert_eq!(t.entries.len(), 4); + assert_eq!(t.entries[3], AgentEntry::Agent { text: "done".into() }); +} + +#[test] +fn whole_messages_add_only_what_their_chunks_did_not_say() { + let mut n = Normalizer::default(); + let chunk = |id: &str, text: &str| json!({ "sessionUpdate": "agent_message_chunk", "messageId": id, "content": { "type": "text", "text": text } }); + let message = |kind: &str, id: &str, text: &str| json!({ "sessionUpdate": kind, "messageId": id, "content": [{ "type": "text", "text": text }] }); + assert_eq!(n.normalize("s", &chunk("m1", "Hel")).len(), 1); + let out = n.normalize("s", &message("agent_message", "m1", "Hello")); + assert_eq!(out, [json!({ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "lo" } })]); + // Repeated: nothing new. + assert!(n.normalize("s", &message("agent_message", "m1", "Hello")).is_empty()); + // A message never streamed, and a thought. + assert_eq!(n.normalize("s", &message("agent_message", "m2", "Hi"))[0]["content"]["text"], "Hi"); + let thought = n.normalize("s", &message("agent_thought", "m3", "hmm")); + assert_eq!(thought[0]["sessionUpdate"], "agent_thought_chunk"); + // Cleared (replay), then streamed again: the chunks pass. + assert!( + n.normalize("s", &json!({ "sessionUpdate": "agent_message", "messageId": "m1", "content": [] })).is_empty() + ); + assert_eq!(n.normalize("s", &chunk("m1", "Hello"))[0]["content"]["text"], "Hello"); + // The prompt's echo is the client's own entry. + assert!(n.normalize("s", &message("user_message", "u1", "Fix it")).is_empty()); +} + +#[test] +fn tool_calls_are_announced_once_and_content_chunks_append() { + let mut n = Normalizer::default(); + let out = n.normalize( + "s", + &json!({ "sessionUpdate": "tool_call_update", "toolCallId": "t", "name": "Read", "status": "pending" }), + ); + assert_eq!(out[0]["sessionUpdate"], "tool_call"); + assert_eq!(out[0]["title"], "Read", "the name stands in for a missing title"); + let text = |t: &str| json!({ "type": "content", "content": { "type": "text", "text": t } }); + let out = n.normalize( + "s", + &json!({ "sessionUpdate": "tool_call_content_chunk", "toolCallId": "t", "content": text("a") }), + ); + assert_eq!(out[0]["sessionUpdate"], "tool_call_update"); + n.normalize("s", &json!({ "sessionUpdate": "tool_call_content_chunk", "toolCallId": "t", "content": text("b") })); + let out = n.normalize( + "s", + &json!({ "sessionUpdate": "tool_call_update", "toolCallId": "t", "title": null, "status": "completed" }), + ); + assert_eq!(out[0], json!({ "sessionUpdate": "tool_call_update", "toolCallId": "t", "status": "completed" })); + let mut t = Timeline::default(); + for u in [ + json!({ "sessionUpdate": "tool_call_update", "toolCallId": "u", "title": "Grep" }), + json!({ "sessionUpdate": "tool_call_content_chunk", "toolCallId": "u", "content": text("one") }), + json!({ "sessionUpdate": "tool_call_content_chunk", "toolCallId": "u", "content": text("two") }), + ] { + for v in n.normalize("s", &u) { + t.apply(&v); + } + } + assert_eq!(tool(&t.entries[0]).2, Some("one\ntwo")); + // Tool calls are per session: a subagent's `t` is another call. + assert_eq!( + n.normalize("c", &json!({ "sessionUpdate": "tool_call_update", "toolCallId": "t" }))[0]["sessionUpdate"], + "tool_call" + ); +} + +#[test] +fn terminal_output_reaches_the_tool_calls_that_show_it() { + let mut n = Normalizer::default(); + let mut t = Timeline::default(); + let data = |s: &str| { + // `base64` of `s`, by hand: these are short. + const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for c in s.as_bytes().chunks(3) { + let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)]; + let v = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + for i in 0..4 { + out.push(if i <= c.len() { A[(v >> (18 - 6 * i) & 63) as usize] as char } else { '=' }); + } + } + out + }; + for u in [ + json!({ "sessionUpdate": "terminal_update", "terminalId": "x" }), + json!({ "sessionUpdate": "tool_call_update", "toolCallId": "t", "title": "make", "kind": "execute", + "content": [{ "type": "terminal", "terminalId": "x" }] }), + json!({ "sessionUpdate": "terminal_output_chunk", "terminalId": "x", "data": data("build") }), + json!({ "sessionUpdate": "terminal_output_chunk", "terminalId": "x", "data": data("ing…\nok") }), + json!({ "sessionUpdate": "terminal_update", "terminalId": "x", "exitStatus": { "exitCode": 0 } }), + json!({ "sessionUpdate": "tool_call_update", "toolCallId": "t", "status": "completed" }), + ] { + for v in n.normalize("s", &u) { + t.apply(&v); + } + } + assert_eq!(tool(&t.entries[0]), ("make", AgentToolStatus::Completed, Some("building…\nok"))); + assert_eq!(base64("aGk="), b"hi"); + assert_eq!(base64("aGk"), b"hi"); +} + +#[test] +fn item_plans_become_the_plan_and_others_are_left_to_their_tool_call() { + let mut n = Normalizer::default(); + let items = json!({ "sessionUpdate": "plan_update", "plan": { "type": "items", "planId": "plan", + "entries": [{ "content": "look", "priority": "medium", "status": "completed" }, + { "content": "answer", "priority": "medium", "status": "in_progress" }] } }); + let out = n.normalize("s", &items); + assert_eq!(out, [json!({ "sessionUpdate": "plan", "entries": items["plan"]["entries"] })]); + let md = + json!({ "sessionUpdate": "plan_update", "plan": { "type": "markdown", "planId": "p2", "content": "# Plan" } }); + assert!(n.normalize("s", &md).is_empty()); + assert!(n.normalize("s", &json!({ "sessionUpdate": "plan_removed", "planId": "p2" })).is_empty()); + let out = n.normalize("s", &json!({ "sessionUpdate": "plan_removed", "planId": "plan" })); + assert_eq!(out, [json!({ "sessionUpdate": "plan", "entries": [] })]); +} + +#[test] +fn a_compaction_shows_as_a_tool_call() { + let mut n = Normalizer::default(); + let mut t = Timeline::default(); + for u in [ + json!({ "sessionUpdate": "compaction_update", "compactionId": "c1", "status": "in_progress" }), + json!({ "sessionUpdate": "compaction_summary_chunk", "compactionId": "c1", "content": { "type": "text", "text": "Fixed " } }), + json!({ "sessionUpdate": "compaction_summary_chunk", "compactionId": "c1", "content": { "type": "text", "text": "the lexer" } }), + json!({ "sessionUpdate": "compaction_update", "compactionId": "c1", "status": "completed" }), + ] { + for v in n.normalize("s", &u) { + t.apply(&v); + } + } + assert_eq!(tool(&t.entries[0]), ("Compact conversation", AgentToolStatus::Completed, Some("Fixed the lexer"))); +} + +#[test] +fn git_patches_count_their_lines_per_file() { + let patch = "diff --git a/src/a.rs b/src/a.rs\n--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1,3 +1,3 @@\n fn a() {\n- 1\n+ 2\n }\n@@ -10,2 +10,3 @@\n x\n+y\n z\ndiff --git a/old.rs b/old.rs\ndeleted file mode 100644\n--- a/old.rs\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-a\n-b\n"; + let update = json!({ "sessionUpdate": "tool_call_update", "toolCallId": "t", "title": "Edit", "content": [{ "type": "diff", + "changes": [{ "operation": "modify", "path": "/repo/src/a.rs" }, { "operation": "delete", "path": "/repo/old.rs" }, + { "operation": "add", "path": "/repo/bin.png", "fileType": "binary" }], + "patch": { "format": "git_patch", "text": patch } }] }); + let mut t = Timeline::default(); + for v in Normalizer::default().normalize("s", &update) { + t.apply(&v); + } + let AgentEntry::Tool { diffs, .. } = &t.entries[0] else { panic!() }; + let stats: Vec<(&str, u32, u32)> = diffs.iter().map(|d| (d.path.0.to_str().unwrap(), d.added, d.removed)).collect(); + assert_eq!(stats, [("/repo/src/a.rs", 2, 1), ("/repo/old.rs", 0, 2), ("/repo/bin.png", 0, 0)]); +} + +/// Records what reaches the task's handler. +#[derive(Default)] +struct Recorder { + seen: parking_lot::Mutex>, +} + +impl Handler for Recorder { + fn on_notification(&self, method: &str, params: Value) { + self.seen.lock().push((method.to_owned(), params)); + } + fn on_request(&self, method: &str, params: Value) -> Result { + self.seen.lock().push((method.to_owned(), params)); + Ok(json!({ "outcome": { "outcome": "selected", "optionId": "allow-once" } })) + } +} + +#[test] +fn v2_permission_requests_reach_the_task_with_their_tool_call() { + let recorder = Arc::new(Recorder::default()); + let dialect = Dialect::wrap(recorder.clone()); + dialect.generation.store(2, Ordering::Release); + let request = frames(TURN, "in").into_iter().find(|m| m["method"] == "session/request_permission").unwrap(); + let answer = dialect.on_request("session/request_permission", request["params"].clone()).unwrap(); + assert_eq!(answer["outcome"]["optionId"], "allow-once"); + let seen = recorder.seen.lock(); + let params = &seen[0].1; + assert_eq!(params["toolCall"]["title"], "Write /work/proj/note.txt"); + assert_eq!(params["toolCall"]["toolCallId"], "toolu_01QuF3wSLUeeMp5QTuiNoTBn"); + assert_eq!(params["toolCall"]["content"][0]["path"], "/work/proj/note.txt"); + assert_eq!(params["options"].as_array().unwrap().len(), request["params"]["options"].as_array().unwrap().len()); + + // A v1 dialect leaves both directions alone. + let recorder = Arc::new(Recorder::default()); + let dialect = Dialect::wrap(recorder.clone()); + dialect.generation.store(1, Ordering::Release); + let update = + json!({ "sessionId": "s", "update": { "sessionUpdate": "tool_call", "toolCallId": "t", "title": "x" } }); + dialect.on_notification("session/update", update.clone()); + assert_eq!(recorder.seen.lock()[0].1, update); +} + +/// A scripted v2 agent: answers each request from `answers`, and on `session/prompt` +/// answers at once, then (on its own thread) sends `then`. +struct ScriptedAgent { + peer: std::sync::OnceLock, + then: parking_lot::Mutex>, + prompt: Result, + requests: parking_lot::Mutex>, +} + +impl Handler for ScriptedAgent { + fn on_notification(&self, method: &str, params: Value) { + self.requests.lock().push((method.to_owned(), params)); + } + + fn on_request(&self, method: &str, params: Value) -> Result { + self.requests.lock().push((method.to_owned(), params.clone())); + match method { + "initialize" => Ok(result(TURN, 1)), + "session/new" => Ok(result(TURN, 2)), + "session/resume" => Ok(result(RESUME, 2)), + "session/prompt" => { + let peer = self.peer.get().unwrap().clone(); + let then = std::mem::take(&mut *self.then.lock()); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(20)); + for m in then { + peer.notify("session/update", &m).unwrap(); + } + }); + self.prompt.clone() + } + _ => Ok(json!({})), + } + } +} + +async fn connect(agent: Arc) -> (Conn, Arc) { + let (a, b) = tokio::io::duplex(1 << 20); + let (ar, aw) = tokio::io::split(a); + let (br, bw) = tokio::io::split(b); + let _ = agent.peer.set(Peer::connect_framed(br, bw, agent.clone(), Framing::Lines)); + let recorder = Arc::new(Recorder::default()); + let dialect = Dialect::wrap(recorder.clone()); + let conn = Conn::new(Peer::connect_framed(ar, aw, dialect.clone(), Framing::Lines), dialect); + let n = conn.initialize(&json!({ "name": "OpenIDEA", "version": "0" })).await.unwrap(); + assert_eq!((n.generation, conn.generation()), (2, 2)); + (conn, recorder) +} + +fn scripted(prompt: Result, then: Vec) -> Arc { + Arc::new(ScriptedAgent { + peer: Default::default(), + then: parking_lot::Mutex::new(then), + prompt, + requests: Default::default(), + }) +} + +fn state(session: &str, state: Value) -> Value { + let mut update = json!({ "sessionUpdate": "state_update" }); + update.as_object_mut().unwrap().extend(state.as_object().unwrap().clone()); + json!({ "sessionId": session, "update": update }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_v2_prompt_resolves_when_its_session_goes_idle() { + // The captured cancelled turn: running, a cancel, then `idle` with `cancelled`. + let session = "caf5db42-df04-40e3-bb8b-e270510e17ca"; + let tail: Vec = frames(CANCEL, "in") + .into_iter() + .skip_while(|m| m["id"] != 4) + .filter(|m| m["method"] == "session/update") + .map(|m| m["params"].clone()) + .collect(); + // A stale snapshot and a subagent's own `idle` come first: neither ends the turn. + let mut then = vec![ + state(session, json!({ "state": "idle" })), + state("child", json!({ "state": "idle", "stopReason": "end_turn" })), + ]; + then.extend(tail); + let agent = scripted(Ok(result(CANCEL, 4)), then); + let (conn, recorder) = connect(agent.clone()).await; + let reply: Value = + conn.request("session/prompt", json!({ "sessionId": session, "prompt": [] }), None).await.unwrap(); + assert_eq!(reply["stopReason"], "cancelled"); + assert!(reply["usage"].is_object()); + // Everything before the `idle` reached the task first; no state updates did. + let seen = recorder.seen.lock(); + assert!(seen.iter().any(|(_, p)| p["update"]["sessionUpdate"] == "session_info_update")); + assert!(seen.iter().all(|(_, p)| p["update"]["sessionUpdate"] != "state_update")); + assert!(conn.dialect.turns.lock().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_v2_turn_is_an_error_and_a_queued_cancel_is_a_cancelled_turn() { + let failed = state( + "s", + json!({ "state": "idle", "stopReason": "error", + "error": { "code": -32000, "message": "Authentication required" } }), + ); + let agent = scripted(Ok(json!({ "messageId": "m" })), vec![failed]); + let (conn, _) = connect(agent).await; + let err = + conn.request::<_, Value>("session/prompt", json!({ "sessionId": "s", "prompt": [] }), None).await.unwrap_err(); + assert!( + matches!(err, RpcError::Server { code: -32000, ref message, .. } if message == "Authentication required"), + "{err:?}" + ); + + let agent = scripted(Err((codes::REQUEST_CANCELLED, "cancelled".into())), Vec::new()); + let (conn, _) = connect(agent).await; + let reply: Value = conn.request("session/prompt", json!({ "sessionId": "s", "prompt": [] }), None).await.unwrap(); + assert_eq!(reply["stopReason"], "cancelled"); + + // The agent going away fails the turn. + let agent = scripted(Ok(json!({ "messageId": "m" })), Vec::new()); + let (conn, _) = connect(agent.clone()).await; + let prompt = conn.request::<_, Value>("session/prompt", json!({ "sessionId": "s", "prompt": [] }), None); + let killer = async { + tokio::time::sleep(Duration::from_millis(50)).await; + agent.peer.get().unwrap().kill(); + }; + let (r, ()) = tokio::join!(prompt, killer); + assert!(matches!(r, Err(RpcError::Closed)), "{r:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn v1_requests_are_sent_in_v2() { + let agent = scripted(Ok(json!({})), Vec::new()); + let (conn, recorder) = connect(agent.clone()).await; + let _: Value = conn.request("session/new", json!({ "cwd": "/p", "mcpServers": [] }), None).await.unwrap(); + let _: Value = + conn.request("session/load", json!({ "sessionId": "s1", "cwd": "/p", "mcpServers": [] }), None).await.unwrap(); + let _: Value = conn + .request("session/set_config_option", json!({ "sessionId": "s1", "configId": "model", "value": "haiku" }), None) + .await + .unwrap(); + let _: Value = conn.request("authenticate", json!({ "methodId": "x" }), None).await.unwrap(); + let requests = agent.requests.lock().clone(); + let methods: Vec<&str> = requests.iter().map(|(m, _)| m.as_str()).collect(); + assert_eq!(methods, ["initialize", "session/new", "session/resume", "session/set_config_option", "auth/login"]); + assert!(requests[2].1.get("replayFrom").is_none(), "resumed without a replay"); + assert_eq!(requests[3].1["type"], "id"); + // The commands of both setup responses, as v1 updates. + let seen = recorder.seen.lock(); + let commands: Vec<&Value> = seen + .iter() + .filter(|(_, p)| p["update"]["sessionUpdate"] == "available_commands_update") + .map(|(_, p)| &p["sessionId"]) + .collect(); + assert_eq!(commands, [&json!("28846a4c-c3e8-4a45-a3d0-c19f10d19d1a"), &json!("s1")]); +} diff --git a/crates/ide-agent/src/conversation.rs b/crates/ide-agent/src/conversation.rs index 1173efda9..512a13b26 100644 --- a/crates/ide-agent/src/conversation.rs +++ b/crates/ide-agent/src/conversation.rs @@ -156,6 +156,19 @@ impl Conversation { changed.map(|i| (parent, i)) } + /// Keeps the task's first `len` entries, and the subagents still shown in them. + pub fn truncate(&mut self, len: usize) { + let mut entries = std::mem::take(&mut self.main.entries); + entries.truncate(len); + self.main = Timeline::from_entries(entries); + let shown = |c: &Conversation, id: &str| { + c.main.entries.iter().any(|e| matches!(e, AgentEntry::Subagent { id: card, .. } if card == id)) + }; + let keep: Vec = self.subagents.iter().map(|s| s.parent.is_some() || shown(self, &s.id)).collect(); + let mut keep = keep.into_iter(); + self.subagents.retain(|_| keep.next().unwrap_or(true)); + } + /// Settles what an ended turn of the task left open. Subagents work on (in the background). pub fn end_turn(&mut self, done: Option) -> Vec<(Session, usize)> { self.main.end_turn(done).into_iter().map(|i| (None, i)).collect() diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index ecf5906cd..a7d7962c6 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -7,10 +7,13 @@ //! project's store directory and survive restarts; the agent session is reloaded on the //! next prompt when the agent supports `session/load`. +mod acp; mod config; mod conversation; mod model; +mod native; mod provider; +pub mod registry; mod skills; mod timeline; mod worktree; @@ -23,7 +26,7 @@ use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes}; use parking_lot::{Mutex, RwLock}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Arc, Weak}; @@ -45,6 +48,8 @@ const SESSION_STATE: [&str; 5] = ["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"]; /// Referenced files up to this size are embedded in the prompt (when the agent takes it). const MAX_EMBED: u64 = 256 * 1024; +/// How long a probe waits for the agent to answer `initialize`. +const PROBE_TIMEOUT: Duration = Duration::from_secs(30); /// The agent tasks of one project. pub struct AgentManager { @@ -80,6 +85,8 @@ struct Shared { sink: RwLock>, known: Mutex>, providers: RwLock>, + /// Last probe of each provider. + probes: Mutex>, } /// What an agent offered in its last session, for the New Task page. @@ -161,6 +168,9 @@ struct TaskFile { before: BTreeMap, #[serde(default, skip_serializing_if = "Vec::is_empty")] subagents: Vec, + /// Turns to drop from the session once it is loaded (a fork of an earlier point). + #[serde(default, skip_serializing_if = "is_zero")] + pending_rollback: u32, } struct TaskState { @@ -169,6 +179,7 @@ struct TaskState { conversation: Conversation, wanted: Vec, before: Arc>, + pending_rollback: u32, /// A turn is running or about to (prompts arriving now are queued). busy: bool, } @@ -176,10 +187,9 @@ struct TaskState { /// A running agent and its session. #[derive(Clone)] struct Live { - peer: Peer, + peer: acp::Conn, session: String, - /// `promptCapabilities.image`. - images: bool, + capabilities: AgentCapabilities, /// `promptCapabilities.embeddedContext`: referenced files are sent with their text. embedded: bool, } @@ -198,6 +208,13 @@ struct Task { replaying: AtomicBool, /// The current turn is being cancelled to send the first queued message right away. send_now: AtomicBool, + /// The user stopped the current turn. A stop that reaches the agent before it took the + /// prompt can be ignored, so it is sent again once the turn shows signs of life. + cancel_pending: AtomicBool, + /// The agent reported something for the current turn. + turn_started: AtomicBool, + /// Settings already sent this session (`(option, value)`), so a refusal is not retried. + config_sent: Mutex>, dirty: Mutex>, flush_scheduled: AtomicBool, stderr: Mutex>, @@ -208,8 +225,12 @@ struct Task { impl AgentManager { /// Loads the project's saved tasks. Spawned work runs on `runtime`. pub fn new(paths: AgentPaths, runtime: tokio::runtime::Handle) -> Self { - let connector: Connector = Arc::new(|spec, cwd, client, on_stderr| { - Peer::spawn_framed(&spec.command, &spec.args, cwd, &spec.env, client, on_stderr, Framing::Lines) + let connector: Connector = Arc::new(|spec, cwd, client, on_stderr| match spec.driver { + provider::Driver::Acp => { + Peer::spawn_framed(&spec.command, &spec.args, cwd, &spec.env, client, on_stderr, Framing::Lines) + } + provider::Driver::Codex => native::codex::connect(spec, cwd, client, on_stderr), + provider::Driver::Claude => native::claude::connect(spec, cwd, client, on_stderr), }); Self::with_connector(paths, runtime, connector) } @@ -225,6 +246,7 @@ impl AgentManager { sink: RwLock::new(None), known: Mutex::new(known), providers, + probes: Mutex::new(HashMap::new()), }); let mut tasks = HashMap::new(); for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() { @@ -275,6 +297,7 @@ impl AgentManager { installed: provider::installed(&p.command), config: self.shared.known_config(&p.id), commands: self.shared.known_commands(&p.id), + source: p.source.clone(), }) .collect(); *self.shared.providers.write() = specs; @@ -293,7 +316,7 @@ impl AgentManager { } None => (provider.to_owned(), self.shared.known_commands(provider), self.shared.paths.project_root.clone()), }; - if provider != "claude" { + if provider != "claude" && provider != "claude-acp" { return Ok(offered); } let home = self.shared.paths.home_dir.clone(); @@ -364,6 +387,8 @@ impl AgentManager { commands: self.shared.known_commands(&spec.id), usage: None, queue: Vec::new(), + capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(), + resets_ms: None, }; let file = TaskFile { task: task.clone(), @@ -372,6 +397,7 @@ impl AgentManager { wanted: req.config, before, subagents: Vec::new(), + pending_rollback: 0, }; let handle = Task::restore(file, self.shared.clone()); handle.state.lock().busy = true; @@ -387,6 +413,14 @@ impl AgentManager { pub async fn prompt(&self, id: &str, prompt: AgentPrompt, now: bool) -> IdeResult<()> { let task = self.task(id)?; let prompt = clean(prompt).ok_or_else(|| IdeError::invalid("empty message"))?; + // An agent that takes messages mid-turn gets it right away. + let prompt = match now { + true => match task.steer(prompt).await { + Ok(()) => return Ok(()), + Err(prompt) => prompt, + }, + false => prompt, + }; let start = { let mut s = task.state.lock(); if s.task.status == AgentStatus::Archived { @@ -501,6 +535,171 @@ impl AgentManager { Ok(()) } + /// Copies the task's conversation into a new task in the same directory, up to its + /// first `turns` turns (all of them when `None`). The copy loads its own session on + /// its first message. + pub async fn fork(&self, id: &str, turns: Option) -> IdeResult { + let source = self.task(id)?; + if source.state.lock().busy { + return Err(IdeError::invalid("wait until the agent is done")); + } + let live = source.ensure_session().await.map_err(IdeError::internal)?; + let total = turn_starts(&source.state.lock().conversation.main.entries).len() as u32; + let keep = turns.unwrap_or(total).min(total); + if !live.capabilities.fork || (keep < total && !live.capabilities.rollback) { + return Err(IdeError::invalid("this agent can't copy a conversation from there")); + } + let cwd = source.state.lock().task.cwd.0.clone(); + let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned(); + let reply: Value = live + .peer + .request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": [] }), None) + .await + .map_err(|e| IdeError::internal(source.describe(e)))?; + let session = reply + .get("sessionId") + .and_then(Value::as_str) + .ok_or_else(|| IdeError::internal("the agent returned no session"))? + .to_owned(); + let file = { + let s = source.state.lock(); + let mut entries = s.conversation.main.entries.clone(); + if let Some(&cut) = turn_starts(&entries).get(keep as usize) { + entries.truncate(cut); + } + let now = now_ms(); + let task = AgentTask { + id: uuid::Uuid::new_v4().simple().to_string(), + status: AgentStatus::Ready, + error: None, + created_ms: now, + updated_ms: now, + usage: None, + queue: Vec::new(), + resets_ms: None, + ..s.task.clone() + }; + TaskFile { + task, + session_id: Some(session), + entries, + wanted: s.wanted.clone(), + before: (*s.before).clone(), + subagents: s.conversation.saved_subagents(), + pending_rollback: total - keep, + } + }; + let task = Task::restore(file, self.shared.clone()); + self.tasks.write().insert(task.id.clone(), task.clone()); + task.persist(); + task.emit_task(); + let copy = task.state.lock().task.clone(); + Ok(copy) + } + + /// Drops the task's turns after its first `turns`, in the agent's session and in the + /// conversation; returns the message that started the first dropped turn. Files the + /// agent changed stay as they are. + pub async fn rollback(&self, id: &str, turns: u32) -> IdeResult> { + let task = self.task(id)?; + if task.state.lock().busy { + return Err(IdeError::invalid("wait until the agent is done")); + } + let live = task.ensure_session().await.map_err(IdeError::internal)?; + if !live.capabilities.rollback { + return Err(IdeError::invalid("this agent can't undo turns")); + } + let starts = turn_starts(&task.state.lock().conversation.main.entries); + let Some(&cut) = starts.get(turns as usize) else { return Ok(None) }; + let drop = (starts.len() - turns as usize) as u32; + let _: Value = live + .peer + .request("_openidea/rollback", json!({ "sessionId": live.session, "turns": drop }), None) + .await + .map_err(|e| IdeError::internal(task.describe(e)))?; + let first = { + let mut s = task.state.lock(); + let first = match s.conversation.main.entries.get(cut) { + Some(AgentEntry::User { text, .. }) => Some(AgentPrompt { text: text.clone(), ..Default::default() }), + _ => None, + }; + s.conversation.truncate(cut); + s.task.updated_ms = now_ms(); + first + }; + task.persist(); + self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 }); + task.emit_task(); + Ok(first) + } + + /// What `provider` reports about itself, from `initialize` alone: no session is opened, + /// so no MCP server, hook or sign-in runs. Remembered until `refresh`. + pub async fn probe(&self, provider: &str, refresh: bool) -> IdeResult { + if !refresh && let Some(p) = self.shared.probes.lock().get(provider) { + return Ok(p.clone()); + } + let spec = self.shared.spec(provider).ok_or_else(|| IdeError::invalid(format!("unknown agent {provider}")))?; + let mut probe = AgentProbe { + provider: spec.id.clone(), + name: None, + version: None, + protocol: match spec.driver { + provider::Driver::Acp => "ACP".into(), + _ => "native".into(), + }, + capabilities: AgentCapabilities::default(), + auth_methods: Vec::new(), + error: None, + }; + let stderr: Arc>> = Arc::default(); + let lines = stderr.clone(); + let on_stderr: Box = Box::new(move |l| lines.lock().push(l)); + let started = (self.shared.connector)( + &spec, + &self.shared.paths.project_root, + Arc::new(ide_jsonrpc::NoopHandler), + on_stderr, + ); + let reply = match started { + Err(e) => Err(format!("Couldn't start {} (`{}`): {e}", spec.name, spec.command)), + Ok(peer) => { + let params = acp::initialize_params(&client_info()); + let r = tokio::time::timeout(PROBE_TIMEOUT, peer.request::<_, Value>("initialize", params, None)).await; + peer.kill(); + match r { + Ok(Ok(v)) => Ok(v), + Ok(Err(e)) => Err(match stderr.lock().last() { + Some(line) => format!("{e}: {line}"), + None => e.to_string(), + }), + Err(_) => Err("the agent did not answer".into()), + } + } + }; + match reply { + Ok(reply) => { + let init = acp::Negotiated::from_initialize(&reply); + let info = init.agent_info.clone().unwrap_or_default(); + let text = |k: &str| info.get(k).and_then(Value::as_str).map(str::to_owned); + probe.name = text("title").or_else(|| text("name")); + probe.version = text("version"); + if spec.driver == provider::Driver::Acp { + probe.protocol = format!("ACP v{}", init.generation); + } + probe.capabilities = capabilities_of(&init).0; + probe.auth_methods = init + .auth_methods + .iter() + .filter_map(|m| m.get("name").or_else(|| m.get("id")).and_then(Value::as_str).map(str::to_owned)) + .collect(); + } + Err(e) => probe.error = Some(e), + } + self.shared.probes.lock().insert(spec.id, probe.clone()); + Ok(probe) + } + /// Stops the current turn (`session/cancel`); pending permission requests are cancelled. /// Queued messages stay queued. With `subagent`, stops only that subagent's work. pub fn cancel(&self, id: &str, subagent: Option) -> IdeResult<()> { @@ -545,7 +744,9 @@ impl AgentManager { let s = task.state.lock(); (s.task.isolation, s.task.cwd.0.clone()) }; - if remove_worktree && isolation == Isolation::Worktree && cwd.exists() { + // A copy of the task (a fork) may work in the same worktree. + let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd); + if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() { let root = self.shared.paths.project_root.clone(); blocking(move || worktree::remove(&root, &cwd)).await?; } @@ -594,6 +795,7 @@ impl Task { conversation, wanted: file.wanted, before: Arc::new(file.before), + pending_rollback: file.pending_rollback, busy: false, }), live: Mutex::new(None), @@ -602,6 +804,9 @@ impl Task { next_permission: AtomicU32::new(1), replaying: AtomicBool::new(false), send_now: AtomicBool::new(false), + cancel_pending: AtomicBool::new(false), + turn_started: AtomicBool::new(false), + config_sent: Mutex::new(HashSet::new()), dirty: Mutex::new(BTreeSet::new()), flush_scheduled: AtomicBool::new(false), stderr: Mutex::new(VecDeque::new()), @@ -652,6 +857,7 @@ impl Task { wanted: s.wanted.clone(), before: (*s.before).clone(), subagents: s.conversation.saved_subagents(), + pending_rollback: s.pending_rollback, }) }; let path = self.shared.paths.store_dir.join(format!("{}.json", self.id)); @@ -700,6 +906,7 @@ impl Task { async fn run(self: Arc, mut prompt: AgentPrompt) { loop { let result = self.turn(&prompt).await; + self.cancel_pending.store(false, Ordering::Release); tokio::time::sleep(TURN_GRACE).await; let changed = self.state.lock().conversation.end_turn(Some(now_ms())); self.mark_dirty(changed); @@ -729,16 +936,72 @@ impl Task { match (next, result) { (Some(queued), _) => prompt = queued.prompt, (None, Ok(_)) => return self.set_status(AgentStatus::Ready, None), - (None, Err(message)) => { - self.push_entry(AgentEntry::Error { message: message.clone() }); - return self.set_status(AgentStatus::Failed, Some(message)); - } + (None, Err(failure)) => return self.fail(failure), } } } + /// Ends a turn that failed: nothing it started stays open. + fn fail(self: &Arc, failure: Failure) { + self.cancel_permissions(); + let gone = self.live.lock().as_ref().is_none_or(|l| l.peer.is_closed()); + if gone { + let changed = self.state.lock().conversation.settle(now_ms()); + self.mark_dirty(changed); + } + self.push_entry(AgentEntry::Error { message: failure.message.clone() }); + // The next message starts the agent again (loading the session): what failed may + // be fixed outside it meanwhile (signed in, updated, another model chosen). + if !gone && let Some(live) = self.live.lock().take() { + live.peer.kill(); + } + match failure.limit { + Some(resets) => { + self.state.lock().task.resets_ms = resets; + self.set_status(AgentStatus::Limited, Some(failure.message)); + } + None => self.set_status(AgentStatus::Failed, Some(failure.message)), + } + } + + /// Adds `prompt` to the running turn when the agent takes that; gives it back otherwise. + async fn steer(self: &Arc, prompt: AgentPrompt) -> Result<(), AgentPrompt> { + let live = self.live.lock().clone().filter(|l| l.capabilities.steer && !l.peer.is_closed()); + let running = { + let s = self.state.lock(); + s.busy && s.task.status == AgentStatus::Running + }; + let Some(live) = live.filter(|l| running && (prompt.images.is_empty() || l.capabilities.images)) else { + return Err(prompt); + }; + let content = self.content(&live, &prompt).await; + let sent: Result = + live.peer.request("_openidea/steer", json!({ "sessionId": live.session, "prompt": content }), None).await; + if let Err(e) = sent { + // The turn ended meanwhile: the message waits for the next one instead. + tracing::debug!("agent task {}: steering failed: {e}", self.id); + return Err(prompt); + } + let attachments = prompt + .files + .iter() + .map(|f| file_name(&f.0)) + .chain(prompt.images.iter().map(|i| i.name.clone().unwrap_or_else(|| "image".into()))) + .collect(); + self.push_entry(AgentEntry::User { + text: prompt.text, + attachments, + sent_ms: Some(now_ms()), + done_ms: None, + steered: true, + }); + Ok(()) + } + /// Sends one prompt, starting the agent first if needed; returns the stop reason. - async fn turn(self: &Arc, prompt: &AgentPrompt) -> Result { + async fn turn(self: &Arc, prompt: &AgentPrompt) -> Result { + self.turn_started.store(false, Ordering::Release); + self.state.lock().task.resets_ms = None; let attachments = prompt .files .iter() @@ -750,36 +1013,59 @@ impl Task { attachments, sent_ms: Some(now_ms()), done_ms: None, + steered: false, }); // Launching the agent can take a while (`npx` fetches its adapter on first use). let starting = self.live.lock().as_ref().is_none_or(|l| l.peer.is_closed()); self.set_status(if starting { AgentStatus::Starting } else { AgentStatus::Running }, None); - let live = self.ensure_session().await?; - self.set_status(AgentStatus::Running, None); - if !prompt.images.is_empty() && !live.images { - return Err("This agent can't read images.".into()); + let live = self.ensure_session().await.map_err(Failure::from)?; + // Stopped while the agent started: the prompt is never sent. + if self.cancel_pending.load(Ordering::Acquire) { + return Ok("cancelled".into()); } + self.set_status(AgentStatus::Running, None); + if !prompt.images.is_empty() && !live.capabilities.images { + return Err(Failure::from("This agent can't read images.".to_owned())); + } + let content = self.content(&live, prompt).await; + let result: Result = + live.peer.request("session/prompt", json!({ "sessionId": live.session, "prompt": content }), None).await; + match result { + Ok(v) => Ok(v.get("stopReason").and_then(Value::as_str).unwrap_or("end_turn").to_owned()), + Err(e) => Err(self.failure(e)), + } + } + + /// The prompt as ACP content blocks for `live`'s agent. + async fn content(&self, live: &Live, prompt: &AgentPrompt) -> Vec { let contents = if live.embedded && !prompt.files.is_empty() { let files: Vec = prompt.files.iter().map(|f| f.0.clone()).collect(); tokio::task::spawn_blocking(move || files.iter().map(|f| embeddable(f)).collect()).await.unwrap_or_default() } else { Vec::new() }; - let result: Result = live - .peer - .request("session/prompt", json!({ "sessionId": live.session, "prompt": blocks(prompt, &contents) }), None) - .await; - match result { - Ok(v) => Ok(v.get("stopReason").and_then(Value::as_str).unwrap_or("end_turn").to_owned()), + blocks(prompt, &contents) + } + + /// What a failed request means for the user: signed out, out of quota, or the agent's message. + fn failure(&self, e: RpcError) -> Failure { + if let Some(resets) = usage_limit(&e) { + let message = match &e { + RpcError::Server { message, .. } if !message.is_empty() => message.clone(), + _ => "The usage limit was reached.".into(), + }; + return Failure { message, limit: Some(resets) }; + } + match e { // Signed out, or the account was refused (Claude Code: "API Error: 403"). - Err(RpcError::Server { message, .. }) if message.to_lowercase().contains("auth") => { + RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => { let provider = self.state.lock().task.provider.clone(); - Err(match self.shared.spec(&provider) { + Failure::from(match self.shared.spec(&provider) { Some(spec) => format!("{} needs you to sign in. {}", spec.name, spec.login_hint), None => message, }) } - Err(e) => Err(self.describe(e)), + e => Failure::from(self.describe(e)), } } @@ -796,6 +1082,7 @@ impl Task { }; let spec = self.shared.spec(&provider_id).ok_or_else(|| format!("unknown agent {provider_id}"))?; self.stderr.lock().clear(); + self.config_sent.lock().clear(); let weak = Arc::downgrade(self); let on_stderr: Box = Box::new(move |line| { if let Some(t) = weak.upgrade() { @@ -806,33 +1093,17 @@ impl Task { buf.push_back(line); } }); - let client = Arc::new(Client { task: Arc::downgrade(self) }); - let peer = (self.shared.connector)(&spec, &cwd, client, on_stderr) + // Speaks ACP v1 or v2, whichever the agent chooses; the task sees v1 either way. + let client = acp::Dialect::wrap(Arc::new(Client { task: Arc::downgrade(self) })); + let peer = (self.shared.connector)(&spec, &cwd, client.clone(), on_stderr) + .map(|peer| acp::Conn::new(peer, client)) .map_err(|e| format!("Couldn't start {} (`{}`): {e}", spec.name, spec.command))?; - let init: Value = peer - .request( - "initialize", - json!({ - "protocolVersion": 1, - "clientCapabilities": { - "fs": { "readTextFile": false, "writeTextFile": false }, - "terminal": false, - // Subagents as sessions of their own (ACP subagents RFD). - "subagents": {}, - }, - "clientInfo": { "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") }, - }), - None, - ) - .await - .map_err(|e| self.describe(e))?; - let can_load = init.pointer("/agentCapabilities/loadSession").and_then(Value::as_bool) == Some(true); - let capable = |c: &str| { - init.pointer(&format!("/agentCapabilities/promptCapabilities/{c}")).and_then(Value::as_bool) == Some(true) - }; - let (images, embedded) = (capable("image"), capable("embeddedContext")); - let cwd_s = cwd.to_string_lossy().into_owned(); + let init = peer.initialize(&client_info()).await.map_err(|e| self.describe(e))?; + let can_load = init.load_session(); + let (capabilities, embedded) = capabilities_of(&init); + // Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`). + let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned(); let mut session = None; if let (Some(id), true) = (saved, can_load) { @@ -846,6 +1117,7 @@ impl Task { Err(e) => tracing::info!("agent task {}: session/load failed, starting a new session: {e}", self.id), } } + let resumed = session.is_some(); let (session_id, result) = match session { Some(s) => s, None => { @@ -863,6 +1135,19 @@ impl Task { } }; + // A fork of an earlier point: the copied session still has the later turns. + let pending = std::mem::take(&mut self.state.lock().pending_rollback); + if pending > 0 { + if resumed && capabilities.rollback { + let params = json!({ "sessionId": session_id, "turns": pending }); + if let Err(e) = peer.request::<_, Value>("_openidea/rollback", params, None).await { + tracing::warn!("agent task {}: dropping {pending} forked turns failed: {e}", self.id); + } + } else { + tracing::warn!("agent task {}: can't drop {pending} forked turns", self.id); + } + } + // The session's settings, then the user's choices on top. // A session that reports none keeps the last known ones, but what is set is unknown. let reported = config::options_of(&result); @@ -873,11 +1158,13 @@ impl Task { }; self.shared.remember(&provider_id, Some(&options), None); for choice in &wanted { + // An option the session doesn't offer yet is set once it does (`apply_late_choices`). let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue }; let already = known && o.current.as_deref() == Some(choice.value.as_str()); if already || !o.values.iter().any(|v| v.value == choice.value) { continue; } + self.config_sent.lock().insert((choice.id.clone(), choice.value.clone())); let (method, mut params) = config::set_request(&o, &choice.value); params["sessionId"] = json!(session_id); match peer.request::<_, Value>(method, params, None).await { @@ -896,15 +1183,61 @@ impl Task { let mut s = self.state.lock(); s.task.model = model_of(&options).or(s.task.model.take()); s.task.config = options; + s.task.capabilities = capabilities; s.session_id = Some(session_id.clone()); } self.persist(); self.emit_task(); - let live = Live { peer, session: session_id, images, embedded }; + let live = Live { peer, session: session_id, capabilities, embedded }; *self.live.lock() = Some(live.clone()); Ok(live) } + /// Sends the user's choices that the session offers only now: an option advertised + /// after the session started, or a value that appears once a model that has it runs. + fn apply_late_choices(self: &Arc) { + let Some(live) = self.live.lock().clone().filter(|l| !l.peer.is_closed()) else { return }; + let due: Vec<(AgentConfigOption, String)> = { + let s = self.state.lock(); + let mut sent = self.config_sent.lock(); + s.wanted + .iter() + .filter_map(|c| { + let o = s.task.config.iter().find(|o| o.id == c.id)?; + let due = o.current.as_deref() != Some(c.value.as_str()) + && o.values.iter().any(|v| v.value == c.value) + && sent.insert((c.id.clone(), c.value.clone())); + due.then(|| (o.clone(), c.value.clone())) + }) + .collect() + }; + if due.is_empty() { + return; + } + let task = self.clone(); + self.shared.runtime.spawn(async move { + for (option, value) in due { + let (method, mut params) = config::set_request(&option, &value); + params["sessionId"] = json!(live.session); + match live.peer.request::<_, Value>(method, params, None).await { + Ok(reply) => { + let mut s = task.state.lock(); + if let Some(fresh) = reply.get("configOptions") { + config::merge_update(&mut s.task.config, fresh); + } + if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option.id) { + o.current = Some(value); + } + s.task.model = model_of(&s.task.config).or(s.task.model.take()); + } + Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id), + } + } + task.persist(); + task.emit_task(); + }); + } + /// A user-facing message for a failed request, with the agent's last stderr lines when it died. fn describe(&self, e: RpcError) -> String { match e { @@ -980,11 +1313,21 @@ impl Task { self.persist(); } self.emit_task(); + if kind == "config_option_update" && !self.replaying.load(Ordering::Acquire) { + self.apply_late_choices(); + } return; } if self.replaying.load(Ordering::Acquire) { return; } + // The first sign that the agent took the prompt: a stop sent before it is repeated. + if !self.turn_started.swap(true, Ordering::AcqRel) + && self.cancel_pending.load(Ordering::Acquire) + && let Some(live) = self.live.lock().clone() + { + let _ = live.peer.notify("session/cancel", json!({ "sessionId": live.session })); + } self.apply(session, kind, update); } @@ -1061,6 +1404,10 @@ impl Task { } fn cancel(&self) { + // Also before the turn has started; nothing to stop when idle. + if self.state.lock().busy { + self.cancel_pending.store(true, Ordering::Release); + } self.cancel_permissions(); if let Some(live) = self.live.lock().clone() { let _ = live.peer.notify("session/cancel", json!({ "sessionId": live.session })); @@ -1152,6 +1499,63 @@ fn upgrade(mut file: Value) -> Value { file } +/// Why a turn failed. +struct Failure { + message: String, + /// The usage limit was reached: `Some(when it resets, if known)`. + limit: Option>, +} + +impl From for Failure { + fn from(message: String) -> Self { + Self { message, limit: None } + } +} + +/// Whether `e` reports a usage or rate limit, and when it resets: as native agents report +/// it (`data.openidea.usageLimit.resetsAtMs`), or as ACP agents word it. +fn usage_limit(e: &RpcError) -> Option> { + let RpcError::Server { message, data, .. } = e else { return None }; + if let Some(limit) = data.as_ref().and_then(|d| d.pointer("/openidea/usageLimit")) { + return Some(limit.get("resetsAtMs").and_then(Value::as_f64)); + } + let m = message.to_lowercase(); + ["usage limit", "rate limit", "rate_limit", "quota", "too many requests", "429"] + .iter() + .any(|k| m.contains(k)) + .then_some(None) +} + +fn client_info() -> Value { + json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") }) +} + +/// What the agent said it can do, and whether it takes embedded files. +fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) { + let meta = |p: &str| init.meta(p).and_then(Value::as_bool) == Some(true); + let capabilities = AgentCapabilities { + images: init.prompt("image"), + fork: init.session("fork"), + rollback: meta("/openidea/rollback"), + steer: meta("/openidea/steer"), + }; + (capabilities, init.prompt("embeddedContext")) +} + +/// The turns of a conversation: the user's messages that started one. +fn turn_starts(entries: &[AgentEntry]) -> Vec { + entries + .iter() + .enumerate() + .filter(|(_, e)| matches!(e, AgentEntry::User { steered: false, .. })) + .map(|(i, _)| i) + .collect() +} + +fn is_zero(n: &u32) -> bool { + *n == 0 +} + fn model_of(config: &[AgentConfigOption]) -> Option { config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone()) } @@ -1202,5 +1606,9 @@ async fn blocking(f: impl FnOnce() -> IdeResult + Send + ' tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? } +#[cfg(test)] +mod replay; +#[cfg(test)] +mod replay_tests; #[cfg(test)] mod tests; diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index 364629ce0..5df6a47ad 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -15,9 +15,26 @@ pub enum AgentStatus { /// The turn ended; the result can be reviewed or the conversation continued. Ready, Failed, + /// The account's usage limit was reached; the conversation can go on once it resets. + Limited, Archived, } +/// What a task's agent can do beyond a plain conversation, as it reported when its +/// session started. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", default)] +pub struct AgentCapabilities { + /// Images in messages. + pub images: bool, + /// The conversation can be copied into a new task (`session/fork`). + pub fork: bool, + /// Its latest turns can be dropped. + pub rollback: bool, + /// A message can join the running turn instead of waiting for it. + pub steer: bool, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub enum Isolation { @@ -59,6 +76,11 @@ pub struct AgentTask { pub usage: Option, /// Messages waiting for the current turn to end, oldest first. pub queue: Vec, + #[serde(default)] + pub capabilities: AgentCapabilities, + /// When a [`AgentStatus::Limited`] task's limit resets (ms since the epoch), if the agent said. + #[serde(default)] + pub resets_ms: Option, } /// One value of a select-type session setting. @@ -209,6 +231,9 @@ pub enum AgentEntry { /// When the turn it started ended; `None` while it runs or when it was cut off. #[serde(default)] done_ms: Option, + /// Sent into a running turn rather than starting one. + #[serde(default)] + steered: bool, }, Agent { text: String, @@ -282,6 +307,40 @@ pub struct AgentProvider { pub config: Vec, /// Slash commands it offered last time. pub commands: Vec, + pub source: AgentSource, +} + +/// Where an agent comes from. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum AgentSource { + Builtin, + /// Defined by the user in `/agents/.toml`. + Custom, + /// Installed from the ACP Registry. + Registry { + version: String, + /// A newer version the registry offers. + update: Option, + }, +} + +/// What an agent says about itself when it starts (`initialize` only: no session, so no +/// MCP servers, hooks or sign-in run). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentProbe { + pub provider: String, + /// The agent's own name and version, when it reports them. + pub name: Option, + pub version: Option, + /// How it is driven: `ACP v1`, `ACP v2` or `native`. + pub protocol: String, + pub capabilities: AgentCapabilities, + /// Ways to sign in it offers. + pub auth_methods: Vec, + /// Why it could not be started. + pub error: Option, } #[derive(Clone, Debug, Serialize, Deserialize, Type)] @@ -318,6 +377,11 @@ pub enum AgentEvent { Removed { id: String, }, + /// The task's conversation was cut to its first `length` entries (turns rolled back). + Truncated { + task: String, + length: u32, + }, /// Entry `index` of `task` (of its subagent `subagent`) was added or replaced. Entry { task: String, diff --git a/crates/ide-agent/src/native/claude/cli.rs b/crates/ide-agent/src/native/claude/cli.rs new file mode 100644 index 000000000..fca21d858 --- /dev/null +++ b/crates/ide-agent/src/native/claude/cli.rs @@ -0,0 +1,158 @@ +//! Starting `claude` in stream-json mode, one process per session. + +use ide_core::CancellationToken; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use tokio::io::{AsyncRead, AsyncWrite}; + +/// The flags every session runs with: the Agent SDK's, as its process transport passes them. +pub(super) const BASE_ARGS: &[&str] = &[ + "-p", + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--verbose", + "--permission-prompt-tool", + "stdio", + "--include-partial-messages", + "--thinking-display", + "summarized", + "--allow-dangerously-skip-permissions", +]; + +/// How a session's process starts. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Start { + New { + id: String, + }, + Resume { + id: String, + at: Option, + }, + /// A copy of `from` (up to `at`) under the id `id`. + Fork { + from: String, + id: String, + at: Option, + }, +} + +impl Start { + pub fn args(&self) -> Vec { + let mut out: Vec = BASE_ARGS.iter().map(|a| (*a).to_owned()).collect(); + let mut push = |a: &str, v: &str| { + out.push(a.to_owned()); + out.push(v.to_owned()); + }; + match self { + Start::New { id } => push("--session-id", id), + Start::Resume { id, at } => { + push("--resume", id); + if let Some(at) = at { + push("--resume-session-at", at); + } + } + Start::Fork { from, id, at } => { + push("--resume", from); + if let Some(at) = at { + push("--resume-session-at", at); + } + out.push("--fork-session".into()); + out.push("--session-id".into()); + out.push(id.clone()); + } + } + out + } +} + +pub(super) struct Launch { + pub program: String, + pub args: Vec, + pub cwd: PathBuf, + pub env: Vec<(String, String)>, +} + +/// A running CLI: its stdio, and a token that kills it when cancelled. Its stdout ending +/// means it is gone. +pub(super) struct CliIo { + pub stdin: Pin>, + pub stdout: Pin>, + pub kill: CancellationToken, +} + +pub(super) type OnStderr = Arc; + +pub(super) trait Cli: Send + Sync + 'static { + fn launch(&self, launch: &Launch, on_stderr: OnStderr) -> std::io::Result; +} + +/// The real `claude`. +pub(super) struct Process; + +impl Cli for Process { + fn launch(&self, l: &Launch, on_stderr: OnStderr) -> std::io::Result { + use std::process::Stdio; + let mut cmd = tokio::process::Command::new(&l.program); + ide_core::process::prepare(cmd.as_std_mut()); + cmd.args(&l.args) + .current_dir(&l.cwd) + .envs(ide_core::process::shell_env()) + .envs(l.env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + // Started from inside Claude Code (a terminal in it), the CLI would think it is nested. + .env_remove("CLAUDECODE") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd.spawn()?; + let stdin = child.stdin.take().ok_or_else(|| std::io::Error::other("no stdin"))?; + let stdout = child.stdout.take().ok_or_else(|| std::io::Error::other("no stdout"))?; + if let Some(stderr) = child.stderr.take() { + tokio::spawn(ide_jsonrpc::output::read_lines(stderr, move |line| on_stderr(line))); + } + let kill = CancellationToken::new(); + let token = kill.clone(); + tokio::spawn(async move { + tokio::select! { + _ = child.wait() => {} + _ = token.cancelled() => { + let _ = child.start_kill(); + let _ = child.wait().await; + } + } + }); + Ok(CliIo { stdin: Box::pin(stdin), stdout: Box::pin(stdout), kill }) + } +} + +/// Claude Code's configuration directory (`CLAUDE_CONFIG_DIR`, else `~/.claude`). +pub(super) fn config_dir(env: &[(String, String)]) -> Option { + let set = env + .iter() + .find(|(k, _)| k == "CLAUDE_CONFIG_DIR") + .map(|(_, v)| PathBuf::from(v)) + .or_else(|| std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from)); + set.or_else(|| { + std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(|h| Path::new(&h).join(".claude")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_flags() { + let tail = |s: Start| s.args()[BASE_ARGS.len()..].join(" "); + assert_eq!(tail(Start::New { id: "a".into() }), "--session-id a"); + assert_eq!(tail(Start::Resume { id: "a".into(), at: Some("u".into()) }), "--resume a --resume-session-at u"); + assert_eq!( + tail(Start::Fork { from: "a".into(), id: "b".into(), at: None }), + "--resume a --fork-session --session-id b" + ); + } +} diff --git a/crates/ide-agent/src/native/claude/history.rs b/crates/ide-agent/src/native/claude/history.rs new file mode 100644 index 000000000..f1d1a43be --- /dev/null +++ b/crates/ide-agent/src/native/claude/history.rs @@ -0,0 +1,226 @@ +//! Claude Code's session transcripts (`/projects//.jsonl`): the +//! conversation of a session, where to cut it to drop turns, and its title. +//! +//! A transcript is a tree: every entry names its parent (`parentUuid`), and resuming at an +//! earlier entry (`--resume-session-at`) starts a branch there. The conversation is the +//! path from the newest leaf to the root. + +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// The last group of the uuid of a message sent into a running turn (a steer), so a +/// transcript tells them from the messages that started turns. +pub(super) const STEER_MARK: &str = "57ee"; + +/// The transcript of session `id` started in `cwd`. +pub(super) fn transcript(config_dir: &Path, cwd: &Path, id: &str) -> PathBuf { + let projects = config_dir.join("projects"); + let direct = projects.join(escape(cwd)).join(format!("{id}.jsonl")); + if direct.exists() { + return direct; + } + // Long paths are shortened; the session id is unique anyway. + std::fs::read_dir(&projects) + .into_iter() + .flatten() + .flatten() + .map(|d| d.path().join(format!("{id}.jsonl"))) + .find(|p| p.exists()) + .unwrap_or(direct) +} + +/// The project directory name Claude Code uses for `cwd`. +pub(super) fn escape(cwd: &Path) -> String { + cwd.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect() +} + +pub(super) fn read(path: &Path) -> std::io::Result> { + let text = std::fs::read_to_string(path)?; + Ok(text.lines().filter_map(|l| serde_json::from_str(l).ok()).collect()) +} + +fn uuid(e: &Value) -> Option<&str> { + e.get("uuid").and_then(Value::as_str) +} + +fn parent(e: &Value) -> Option<&str> { + e.get("parentUuid").and_then(Value::as_str) +} + +fn kind(e: &Value) -> &str { + e.get("type").and_then(Value::as_str).unwrap_or_default() +} + +fn in_chain(e: &Value) -> bool { + uuid(e).is_some() + && matches!(kind(e), "user" | "assistant" | "system" | "attachment") + && e.get("isSidechain").and_then(Value::as_bool) != Some(true) +} + +/// The conversation: the entries from the root to `upto` (default: the newest leaf), with +/// tool results that branched off it put back after their calls. +pub(super) fn chain<'a>(entries: &'a [Value], upto: Option<&str>) -> Vec<&'a Value> { + let by_id: HashMap<&str, &Value> = + entries.iter().filter(|e| in_chain(e)).filter_map(|e| Some((uuid(e)?, e))).collect(); + let leaf = match upto { + Some(u) => by_id.get(u).copied(), + None => { + let parents: HashSet<&str> = entries.iter().filter(|e| in_chain(e)).filter_map(parent).collect(); + entries.iter().rev().filter(|e| in_chain(e)).find(|e| !parents.contains(uuid(e).unwrap_or_default())) + } + }; + let mut path = Vec::new(); + let mut seen = HashSet::new(); + let mut at = leaf; + while let Some(e) = at { + if !seen.insert(uuid(e).unwrap_or_default()) { + break; + } + path.push(e); + at = parent(e).and_then(|p| by_id.get(p).copied()); + } + path.reverse(); + // Results of parallel tool calls can hang off another branch of the same message. + let answered: HashSet<&str> = path.iter().flat_map(|e| block_ids(e, "tool_result", "tool_use_id")).collect(); + let mut out = Vec::with_capacity(path.len()); + for e in path { + out.push(e); + for call in block_ids(e, "tool_use", "id") { + if answered.contains(call) { + continue; + } + if let Some(r) = entries.iter().find(|r| { + kind(r) == "user" && in_chain(r) && block_ids(r, "tool_result", "tool_use_id").any(|id| id == call) + }) { + out.push(r); + } + } + } + out +} + +fn block_ids<'a>(e: &'a Value, ty: &'a str, key: &'a str) -> impl Iterator + 'a { + e.pointer("/message/content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(move |b| b.get("type").and_then(Value::as_str) == Some(ty)) + .filter_map(move |b| b.get(key).and_then(Value::as_str)) +} + +/// Whether `e` is a message that started a turn: the user's own, not a tool result, a +/// steer, or text Claude Code added. +pub(super) fn is_turn_start(e: &Value) -> bool { + if kind(e) != "user" || e.get("isMeta").and_then(Value::as_bool) == Some(true) { + return false; + } + if uuid(e).is_some_and(|u| u.ends_with(STEER_MARK)) { + return false; + } + if e.pointer("/origin/kind").and_then(Value::as_str).is_some_and(|k| k != "human") { + return false; + } + match e.pointer("/message/content") { + Some(Value::String(s)) => !s.starts_with(" { + !blocks.is_empty() + && blocks.iter().all(|b| b.get("type").and_then(Value::as_str) != Some("tool_result")) + && !blocks.iter().any(|b| { + b.get("text").and_then(Value::as_str).is_some_and(|t| t.starts_with("[Request interrupted")) + }) + } + _ => false, + } +} + +/// Where to resume `chain` to drop its last `turns` turns: the entry before the first +/// dropped turn's message. `Ok(None)`: nothing is left. +pub(super) fn cut(chain: &[&Value], turns: usize) -> Result, String> { + if turns == 0 { + return Ok(chain.last().and_then(|e| uuid(e)).map(str::to_owned)); + } + let starts: Vec<&Value> = chain.iter().copied().filter(|e| is_turn_start(e)).collect(); + if turns > starts.len() { + return Err(format!("the session has only {} turns", starts.len())); + } + let first_dropped = starts[starts.len() - turns]; + Ok(parent(first_dropped).map(str::to_owned)) +} + +/// The newest title Claude Code gave the session. +pub(super) fn title(entries: &[Value]) -> Option { + entries + .iter() + .rev() + .find(|e| kind(e) == "ai-title" || kind(e) == "custom-title") + .and_then(|e| e.get("aiTitle").or_else(|| e.get("customTitle")).and_then(Value::as_str)) + .map(str::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn user(id: &str, parent: Option<&str>, text: &str) -> Value { + json!({"type": "user", "uuid": id, "parentUuid": parent, "message": {"role": "user", "content": [{"type": "text", "text": text}]}}) + } + + fn assistant(id: &str, parent: &str, content: Value) -> Value { + json!({"type": "assistant", "uuid": id, "parentUuid": parent, "message": {"role": "assistant", "content": content}}) + } + + #[test] + fn the_newest_branch_is_the_conversation() { + let entries = vec![ + json!({"type": "attachment", "uuid": "h", "parentUuid": null}), + user("u1", Some("h"), "one"), + assistant("a1", "u1", json!([{"type": "text", "text": "ok"}])), + user("u2", Some("a1"), "two"), + assistant("a2", "u2", json!([{"type": "text", "text": "ok"}])), + json!({"type": "ai-title", "aiTitle": "Words"}), + // Resumed at a1: a new branch. + user("u3", Some("a1"), "three"), + assistant("a3", "u3", json!([{"type": "text", "text": "ok"}])), + ]; + let ids = |c: Vec<&Value>| c.iter().map(|e| e["uuid"].as_str().unwrap().to_owned()).collect::>(); + assert_eq!(ids(chain(&entries, None)), ["h", "u1", "a1", "u3", "a3"]); + assert_eq!(ids(chain(&entries, Some("u2"))), ["h", "u1", "a1", "u2"]); + let c = chain(&entries, None); + assert_eq!(cut(&c, 1), Ok(Some("a1".into()))); + assert_eq!(cut(&c, 2), Ok(Some("h".into()))); + assert!(cut(&c, 3).is_err()); + assert_eq!(cut(&c, 0), Ok(Some("a3".into()))); + assert_eq!(title(&entries).as_deref(), Some("Words")); + } + + #[test] + fn steers_tool_results_and_interruptions_start_no_turn() { + let steer = format!("aaaaaaaa-0000-4000-8000-00000000{STEER_MARK}"); + let entries = vec![ + user("u1", None, "go"), + assistant( + "a1", + "u1", + json!([{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}, + {"type": "tool_use", "id": "t2", "name": "Bash", "input": {}}]), + ), + json!({"type": "user", "uuid": "r1", "parentUuid": "a1", "message": {"content": [{"type": "tool_result", "tool_use_id": "t1"}]}}), + // The second result on a branch of its own. + json!({"type": "user", "uuid": "r2", "parentUuid": "a1", "message": {"content": [{"type": "tool_result", "tool_use_id": "t2"}]}}), + user(&steer, Some("r1"), "also this"), + user("i", Some(&steer), "[Request interrupted by user]"), + ]; + let c = chain(&entries, None); + let ids: Vec<&str> = c.iter().map(|e| e["uuid"].as_str().unwrap()).collect(); + assert_eq!(ids, ["u1", "a1", "r2", "r1", steer.as_str(), "i"]); + assert_eq!(c.iter().filter(|e| is_turn_start(e)).count(), 1); + assert_eq!(cut(&c, 1), Ok(None)); + } + + #[test] + fn project_directory_names() { + assert_eq!(escape(Path::new("/private/tmp/a.b/c_d")), "-private-tmp-a-b-c-d"); + } +} diff --git a/crates/ide-agent/src/native/claude/mod.rs b/crates/ide-agent/src/native/claude/mod.rs new file mode 100644 index 000000000..a15c8860f --- /dev/null +++ b/crates/ide-agent/src/native/claude/mod.rs @@ -0,0 +1,1325 @@ +//! Claude Code driven natively: `claude -p` in stream-json mode, the protocol the Claude +//! Agent SDK speaks to it, behind an in-process ACP endpoint. +//! +//! Each ACP session runs its own `claude` process, whose session id is the ACP one. +//! The CLI writes a session's transcript only once it has a message, so a fork or an +//! undo that has not been prompted yet is kept as a pending start (`/openidea-claude/ +//! .json`): loading that session, from this connection or a fresh one, starts it that +//! way. + +mod cli; +mod history; +mod settings; +mod tools; +mod translate; + +#[cfg(test)] +mod tests; + +use cli::{Cli, Launch, OnStderr, Start}; +use ide_core::CancellationToken; +use ide_jsonrpc::{Handler, Peer, RpcError, codes}; +use parking_lot::Mutex; +use serde_json::{Value, json}; +use settings::Settings; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock, Weak}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::{mpsc, oneshot}; +use translate::{Translator, Update}; + +use crate::provider::ProviderSpec; + +/// How long the CLI gets to answer a control request. +const CONTROL_TIMEOUT: Duration = Duration::from_secs(60); +/// A stop that arrives this long before its prompt (the two race) still stops it. +const EARLY_CANCEL: Duration = Duration::from_secs(2); + +/// Texts with which Claude Code reports a spent usage limit (the Agent SDK's +/// `USAGE_LIMIT_ERROR_PREFIXES`). +const USAGE_LIMIT_PREFIXES: &[&str] = &[ + "You've hit your", + "You've reached your", + "You're out of usage credits", + "Your org is out of usage", + "Your seat type doesn't include", + "Your usage allocation has been disabled", + "Your group's usage limit is set to $0", + "You're out of extra usage", +]; + +pub(crate) fn connect( + spec: &ProviderSpec, + cwd: &Path, + client: Arc, + on_stderr: Box, +) -> std::io::Result { + let config = Config { + program: super::newest_install(&spec.command), + args: spec.args.clone(), + env: spec.env.clone(), + config_dir: cli::config_dir(&spec.env), + pending_dir: std::env::temp_dir().join("openidea-claude"), + cwd: cwd.to_path_buf(), + }; + let shim = Shim::new(config, Arc::new(cli::Process), Arc::from(on_stderr)); + Ok(super::serve(shim, client)) +} + +struct Config { + pub program: String, + /// Extra arguments (a custom agent's), before the session's own. + pub args: Vec, + pub env: Vec<(String, String)>, + /// Claude Code's configuration directory, where transcripts live. + pub config_dir: Option, + /// Pending starts of sessions not written yet. + pub pending_dir: PathBuf, + pub cwd: PathBuf, +} + +pub(super) struct Shim { + me: Weak, + /// Notifications arrive on a thread of their own, outside the runtime. + rt: tokio::runtime::Handle, + config: Config, + cli: Arc, + on_stderr: OnStderr, + client: OnceLock, + sessions: Mutex>>, + /// The client shows subagents as sessions (`clientCapabilities.subagents`). + subagents: AtomicBool, + closed: AtomicBool, +} + +struct Session { + id: String, + cwd: PathBuf, + shim: Weak, + st: Mutex, + /// Serializes (re)starting the process. + starting: tokio::sync::Mutex<()>, +} + +struct State { + proc: Option>, + /// How the process starts next time. + start: Start, + /// `start` is saved as a pending start. + pending: bool, + settings: Settings, + /// The user's choices, re-applied when the process restarts. + chosen: Vec<(String, String)>, + tr: Translator, + turn: Option, + /// A stop that came while no turn ran. + cancel_at: Option, + /// When the usage limit the CLI last reported as reached resets. + limit_resets_ms: Option, + terminal_only: Vec, + titled: bool, +} + +struct Turn { + /// The uuids of its messages: the prompt, then steers. + uuids: Vec, + done: Option>, + cancelled: bool, + failure: Option, +} + +#[derive(Debug, Clone, PartialEq)] +enum TurnEnd { + Stop(&'static str), + Fail { message: String, data: Option }, +} + +struct Proc { + tx: mpsc::UnboundedSender, + pending: Mutex>>>, + /// Client permission requests in flight, by the CLI's request id. + asks: Mutex>, + kill: CancellationToken, + next: AtomicU64, + gone: AtomicBool, +} + +type RpcResult = Result)>; + +fn invalid(message: impl Into) -> (i64, String, Option) { + (codes::INVALID_PARAMS, message.into(), None) +} + +fn internal(message: impl Into) -> (i64, String, Option) { + (codes::INTERNAL_ERROR, message.into(), None) +} + +impl Proc { + fn send(&self, msg: &Value) -> bool { + !self.gone.load(Ordering::Acquire) && self.tx.send(msg.to_string()).is_ok() + } + + async fn control(&self, request: Value) -> Result { + let id = format!("req_{}", self.next.fetch_add(1, Ordering::Relaxed)); + let (tx, rx) = oneshot::channel(); + self.pending.lock().insert(id.clone(), tx); + if !self.send(&json!({ "type": "control_request", "request_id": id, "request": request })) { + self.pending.lock().remove(&id); + return Err("Claude Code is not running".into()); + } + match tokio::time::timeout(CONTROL_TIMEOUT, rx).await { + Ok(Ok(r)) => r, + Ok(Err(_)) => Err("Claude Code exited".into()), + Err(_) => { + self.pending.lock().remove(&id); + Err("Claude Code did not answer".into()) + } + } + } + + fn end(&self) { + self.gone.store(true, Ordering::Release); + for (_, tx) in self.pending.lock().drain() { + let _ = tx.send(Err("Claude Code exited".into())); + } + for (_, t) in self.asks.lock().drain() { + t.cancel(); + } + } +} + +impl Shim { + fn new(config: Config, cli: Arc, on_stderr: OnStderr) -> Arc { + Arc::new_cyclic(|me| Self { + me: me.clone(), + rt: tokio::runtime::Handle::current(), + config, + cli, + on_stderr, + client: OnceLock::new(), + sessions: Mutex::new(HashMap::new()), + subagents: AtomicBool::new(false), + closed: AtomicBool::new(false), + }) + } + + fn notify(&self, updates: Vec) { + let Some(client) = self.client.get() else { return }; + for u in updates { + let _ = client.notify("session/update", json!({ "sessionId": u.session, "update": u.update })); + } + } + + fn session(&self, params: &Value) -> Result, (i64, String, Option)> { + let id = params.get("sessionId").and_then(Value::as_str).unwrap_or_default(); + self.sessions.lock().get(id).cloned().ok_or_else(|| invalid(format!("unknown session {id}"))) + } + + fn cwd_of(&self, params: &Value) -> PathBuf { + let cwd = params.get("cwd").and_then(Value::as_str).map_or_else(|| self.config.cwd.clone(), PathBuf::from); + // The CLI works in the real directory (macOS /tmp is /private/tmp): its transcripts too. + std::fs::canonicalize(&cwd).unwrap_or(cwd) + } + + fn pending_path(&self, id: &str) -> PathBuf { + self.config.pending_dir.join(format!("{id}.json")) + } + + fn save_pending(&self, id: &str, start: Option<&Start>) { + let path = self.pending_path(id); + let result = match start { + Some(s) => std::fs::create_dir_all(&self.config.pending_dir) + .and_then(|()| std::fs::write(&path, serde_json::to_vec(&start_json(s)).unwrap_or_default())), + None => std::fs::remove_file(&path).or_else(|e| match e.kind() { + std::io::ErrorKind::NotFound => Ok(()), + _ => Err(e), + }), + }; + if let Err(e) = result { + tracing::warn!("claude session {id}: saving its pending start: {e}"); + } + } + + fn load_pending(&self, id: &str) -> Option { + let bytes = std::fs::read(self.pending_path(id)).ok()?; + start_from_json(&serde_json::from_slice(&bytes).ok()?) + } + + fn transcript(&self, cwd: &Path, id: &str) -> Option { + Some(history::transcript(self.config.config_dir.as_deref()?, cwd, id)) + } + + /// The conversation a session started with `start` begins with. + fn base_chain(&self, cwd: &Path, start: &Start) -> Result, String> { + let (id, at) = match start { + Start::New { .. } => return Ok(Vec::new()), + Start::Resume { id, at } => (id, at), + Start::Fork { from, at, .. } => (from, at), + }; + let path = self.transcript(cwd, id).ok_or("no Claude Code configuration directory")?; + let entries = history::read(&path).map_err(|e| format!("reading {}: {e}", path.display()))?; + Ok(history::chain(&entries, at.as_deref()).into_iter().cloned().collect()) + } + + fn add_session(self: &Arc, id: &str, cwd: PathBuf, start: Start, pending: bool) -> Arc { + let session = Arc::new(Session { + id: id.to_owned(), + shim: Arc::downgrade(self), + st: Mutex::new(State { + proc: None, + start, + pending, + settings: Settings::default(), + chosen: Vec::new(), + tr: Translator::new(id, cwd.clone(), self.subagents.load(Ordering::Acquire)), + turn: None, + cancel_at: None, + limit_resets_ms: None, + terminal_only: Vec::new(), + titled: false, + }), + cwd, + starting: tokio::sync::Mutex::new(()), + }); + if let Some(old) = self.sessions.lock().insert(id.to_owned(), session.clone()) { + old.stop(); + } + session + } + + /// `claude --version`: "2.1.312 (Claude Code)" → "2.1.312". Sessions start their own + /// processes later, so `initialize` asks the CLI directly. + fn version(&self) -> Option { + let mut cmd = std::process::Command::new(&self.config.program); + ide_core::process::prepare(&mut cmd); + let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?; + let text = String::from_utf8_lossy(&out.stdout); + text.split_whitespace().next().filter(|v| out.status.success() && !v.is_empty()).map(str::to_owned) + } + + fn handle(self: &Arc, method: &str, params: Value) -> RpcResult { + let rt = &self.rt; + match method { + "initialize" => { + let subagents = params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object); + self.subagents.store(subagents, Ordering::Release); + Ok(json!({ + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "promptCapabilities": { "image": true, "embeddedContext": true }, + "sessionCapabilities": { "fork": {} }, + "_meta": { "openidea": { "rollback": true, "steer": true } }, + }, + "agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() }, + "authMethods": [], + })) + } + "session/new" => { + let id = new_uuid(false); + let session = self.add_session(&id, self.cwd_of(¶ms), Start::New { id: id.clone() }, false); + rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?; + session.announce_commands(); + Ok(json!({ "sessionId": id, "configOptions": session.st.lock().settings.options() })) + } + "session/load" | "session/resume" => { + let id = params.get("sessionId").and_then(Value::as_str).ok_or_else(|| invalid("no sessionId"))?; + let cwd = self.cwd_of(¶ms); + let (start, pending) = match self.load_pending(id) { + Some(s) => (s, true), + None if self.transcript(&cwd, id).is_some_and(|p| p.exists()) => { + (Start::Resume { id: id.to_owned(), at: None }, false) + } + None => return Err(invalid(format!("no Claude Code session {id} in {}", cwd.display()))), + }; + let chain = self.base_chain(&cwd, &start).map_err(internal)?; + let session = self.add_session(id, cwd, start, pending); + rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?; + if method == "session/load" { + session.replay(&chain); + } + session.announce_commands(); + Ok(json!({ "configOptions": session.st.lock().settings.options() })) + } + "session/fork" => { + let source = self.session(¶ms)?; + let id = new_uuid(false); + let (start, settings) = { + let st = source.st.lock(); + let start = match &st.start { + Start::New { .. } if !st.pending && st.titled_or_prompted() => { + Start::Fork { from: source.id.clone(), id: id.clone(), at: None } + } + Start::New { .. } => Start::New { id: id.clone() }, + Start::Resume { id: from, at } | Start::Fork { from, at, .. } if st.pending => { + Start::Fork { from: from.clone(), id: id.clone(), at: at.clone() } + } + _ => Start::Fork { from: source.id.clone(), id: id.clone(), at: None }, + }; + (start, st.settings.clone()) + }; + self.save_pending(&id, Some(&start)); + let session = self.add_session(&id, source.cwd.clone(), start, true); + session.st.lock().settings = settings; + let options = session.st.lock().settings.options(); + Ok(json!({ "sessionId": id, "configOptions": options })) + } + "session/set_config_option" | "session/set_mode" => { + let session = self.session(¶ms)?; + let (option, value) = match method { + "session/set_mode" => (settings::MODE.to_owned(), params.get("modeId")), + _ => ( + params.get("configId").and_then(Value::as_str).unwrap_or_default().to_owned(), + params.get("value"), + ), + }; + let value = value.and_then(Value::as_str).ok_or_else(|| invalid("no value"))?.to_owned(); + rt.block_on(session.set(&option, &value)).map_err(internal)?; + Ok(match method { + "session/set_mode" => json!({}), + _ => json!({ "configOptions": session.st.lock().settings.options() }), + }) + } + "session/prompt" => { + let session = self.session(¶ms)?; + let prompt = params.get("prompt").cloned().unwrap_or_default(); + match rt.block_on(session.prompt(&prompt))? { + TurnEnd::Stop(reason) => Ok(json!({ "stopReason": reason })), + TurnEnd::Fail { message, data } => Err((codes::INTERNAL_ERROR, message, data)), + } + } + "_openidea/steer" => { + let session = self.session(¶ms)?; + session.steer(params.get("prompt").cloned().unwrap_or_default()).map_err(invalid)?; + Ok(json!({})) + } + "_openidea/rollback" => { + let session = self.session(¶ms)?; + let turns = params.get("turns").and_then(Value::as_u64).unwrap_or(0) as usize; + rt.block_on(session.rollback(turns)).map_err(internal)?; + Ok(json!({})) + } + "session/close" => { + if let Ok(s) = self.session(¶ms) { + self.sessions.lock().remove(&s.id); + s.stop(); + } + Ok(json!({})) + } + _ => Err((codes::METHOD_NOT_FOUND, format!("unsupported method {method}"), None)), + } + } + + /// Why a session could not start; it is dropped. + fn start_error(&self, session: &Session, e: String) -> (i64, String, Option) { + self.sessions.lock().remove(&session.id); + let message = if e.to_lowercase().contains("login") || e.contains("401") { + format!("Claude Code authentication failed: {e}") + } else { + e + }; + internal(message) + } + + fn cancel(self: &Arc, params: &Value) { + let id = params.get("sessionId").and_then(Value::as_str).unwrap_or_default(); + let sessions = self.sessions.lock().clone(); + if let Some(s) = sessions.get(id) { + s.cancel(); + return; + } + // A subagent: stop its task. + for s in sessions.values() { + let (agent, proc) = { + let st = s.st.lock(); + (st.tr.agent_of(id), st.proc.clone()) + }; + if let (Some(task), Some(proc)) = (agent, proc) { + self.rt.spawn(async move { + if let Err(e) = proc.control(json!({ "subtype": "stop_task", "task_id": task })).await { + tracing::debug!("stopping subagent {task}: {e}"); + } + }); + return; + } + } + } +} + +impl State { + /// Whether the process ever ran a turn (its transcript exists). + fn titled_or_prompted(&self) -> bool { + self.titled || self.tr.used.is_some() + } +} + +impl Handler for Shim { + fn on_notification(&self, method: &str, params: Value) { + if method == "session/cancel" + && let Some(me) = self.self_arc() + { + me.cancel(¶ms); + } + } + + fn on_request(&self, method: &str, params: Value) -> Result { + self.on_request_with_data(method, params).map_err(|(c, m, _)| (c, m)) + } + + fn on_request_with_data(&self, method: &str, params: Value) -> RpcResult { + let me = self.self_arc().ok_or_else(|| internal("closed"))?; + me.handle(method, params) + } +} + +impl Shim { + fn self_arc(&self) -> Option> { + self.me.upgrade() + } +} + +impl super::Shim for Shim { + fn attach(&self, client: Peer) { + let _ = self.client.set(client.clone()); + // The client went away: so do the processes. + let weak = self.me.clone(); + tokio::spawn(async move { + client.closed().await; + if let Some(me) = weak.upgrade() { + me.closed.store(true, Ordering::Release); + let sessions: Vec> = me.sessions.lock().drain().map(|(_, s)| s).collect(); + for s in sessions { + s.stop(); + } + } + }); + } +} + +impl Session { + fn shim(&self) -> Option> { + self.shim.upgrade() + } + + fn proc(&self) -> Option> { + self.st.lock().proc.clone().filter(|p| !p.gone.load(Ordering::Acquire)) + } + + /// Kills the process (on purpose: the client stays). + fn stop(&self) { + let proc = self.st.lock().proc.take(); + if let Some(p) = proc { + p.kill.cancel(); + p.end(); + } + self.fail_turn("Claude Code was stopped."); + } + + fn fail_turn(&self, message: &str) { + let turn = self.st.lock().turn.take(); + if let Some(mut t) = turn + && let Some(done) = t.done.take() + { + let end = if t.cancelled { + TurnEnd::Stop("cancelled") + } else { + TurnEnd::Fail { message: message.into(), data: None } + }; + let _ = done.send(end); + } + } + + /// Starts the process when it is not running. + async fn ensure(self: &Arc) -> Result, String> { + let _guard = self.starting.lock().await; + if let Some(p) = self.proc() { + return Ok(p); + } + let start = self.st.lock().start.clone(); + self.launch(start).await + } + + async fn launch(self: &Arc, start: Start) -> Result, String> { + let shim = self.shim().ok_or("closed")?; + if let Some(old) = self.st.lock().proc.take() { + old.kill.cancel(); + old.end(); + } + let mut args = shim.config.args.clone(); + args.extend(start.args()); + let launch = + Launch { program: shim.config.program.clone(), args, cwd: self.cwd.clone(), env: shim.config.env.clone() }; + let io = shim + .cli + .launch(&launch, shim.on_stderr.clone()) + .map_err(|e| format!("Couldn't start Claude Code (`{}`): {e}", shim.config.program))?; + let (tx, mut rx) = mpsc::unbounded_channel::(); + let proc = Arc::new(Proc { + tx, + pending: Mutex::new(HashMap::new()), + asks: Mutex::new(HashMap::new()), + kill: io.kill.clone(), + next: AtomicU64::new(1), + gone: AtomicBool::new(false), + }); + let mut stdin = io.stdin; + let kill = io.kill.clone(); + tokio::spawn(async move { + loop { + let line = tokio::select! { + l = rx.recv() => l, + _ = kill.cancelled() => None, + }; + let Some(line) = line else { break }; + let ok = stdin.write_all(line.as_bytes()).await.is_ok() + && stdin.write_all(b"\n").await.is_ok() + && stdin.flush().await.is_ok(); + if !ok { + break; + } + } + let _ = stdin.shutdown().await; + }); + let weak = Arc::downgrade(self); + let reader_proc = proc.clone(); + let stdout = io.stdout; + let kill = io.kill.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + loop { + let line = tokio::select! { + l = lines.next_line() => l.ok().flatten(), + _ = kill.cancelled() => None, + }; + let Some(line) = line else { break }; + let Ok(msg) = serde_json::from_str::(&line) else { + if !line.trim().is_empty() + && let Some(s) = weak.upgrade().and_then(|s| s.shim()) + { + (s.on_stderr)(line); + } + continue; + }; + let Some(session) = weak.upgrade() else { break }; + session.on_cli(&reader_proc, msg); + } + reader_proc.end(); + if let Some(session) = weak.upgrade() { + session.on_exit(&reader_proc); + } + }); + let init = proc.control(json!({ "subtype": "initialize" })).await?; + let applied = proc.control(json!({ "subtype": "get_settings" })).await.unwrap_or_default(); + let chosen = { + let mut st = self.st.lock(); + st.settings = Settings::from_cli(&init, &applied); + st.proc = Some(proc.clone()); + st.start = start; + st.chosen.clone() + }; + for (option, value) in chosen { + if let Err(e) = self.apply(&proc, &option, &value).await { + tracing::warn!("claude session {}: setting {option} to {value}: {e}", self.id); + } + } + Ok(proc) + } + + async fn apply(&self, proc: &Proc, option: &str, value: &str) -> Result<(), String> { + let request = match option { + settings::MODE => json!({ "subtype": "set_permission_mode", "mode": value }), + settings::MODEL => json!({ "subtype": "set_model", "model": value }), + settings::EFFORT => { + let level = if value == "default" { Value::Null } else { json!(value) }; + json!({ "subtype": "apply_flag_settings", "settings": { "effortLevel": level } }) + } + _ => return Err(format!("no setting {option}")), + }; + proc.control(request).await?; + let mut st = self.st.lock(); + match option { + settings::MODE => st.settings.mode = value.to_owned(), + settings::MODEL => st.settings.set_model(value), + _ => st.settings.effort = value.to_owned(), + } + Ok(()) + } + + async fn set(self: &Arc, option: &str, value: &str) -> Result<(), String> { + let proc = self.ensure().await?; + if !self.st.lock().settings.valid(option, value) { + return Err(format!("{option} has no value {value}")); + } + self.apply(&proc, option, value).await?; + let mut st = self.st.lock(); + st.chosen.retain(|(o, _)| o != option); + st.chosen.push((option.to_owned(), value.to_owned())); + Ok(()) + } + + fn announce_commands(self: &Arc) { + let me = self.clone(); + // After the response that makes the session known. + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let commands = me.st.lock().settings.commands.clone(); + if let Some(shim) = me.shim() { + shim.notify(vec![Update { + session: me.id.clone(), + update: json!({ "sessionUpdate": "available_commands_update", "availableCommands": commands }), + }]); + } + }); + } + + /// Sends the history of a loaded session. + fn replay(&self, chain: &[Value]) { + let mut updates = Vec::new(); + { + let mut st = self.st.lock(); + for e in chain { + match e.get("type").and_then(Value::as_str) { + Some("user") if history::is_turn_start(e) || e.pointer("/message/content").is_some() => { + let is_prompt = history::is_turn_start(e); + let msg = json!({ "type": "user", "message": e.get("message"), + "parent_tool_use_id": Value::Null, "tool_use_result": e.get("toolUseResult") }); + updates.extend(if is_prompt { st.tr.on_history_user(&msg) } else { st.tr.on_message(&msg) }); + } + Some("assistant") => { + let msg = json!({ "type": "assistant", "message": e.get("message"), "parent_tool_use_id": Value::Null }); + updates.extend(st.tr.on_message(&msg)); + } + _ => {} + } + } + // Background subagents of the old process are gone. + updates.extend(st.tr.stop_children()); + if let Some(title) = chain_title(self) { + st.titled = true; + updates.push(Update { + session: self.id.clone(), + update: json!({ "sessionUpdate": "session_info_update", "title": title }), + }); + } + } + if let Some(shim) = self.shim() { + shim.notify(updates); + } + } + + async fn prompt(self: &Arc, prompt: &Value) -> Result)> { + let proc = self.ensure().await.map_err(internal)?; + let uuid = new_uuid(false); + let message = user_message(prompt, &uuid, false); + let rx = { + let mut st = self.st.lock(); + if st.turn.is_some() { + return Err(invalid("a turn is already running")); + } + // Stopped before it got here. + if st.cancel_at.take().is_some_and(|t| t.elapsed() < EARLY_CANCEL) { + return Ok(TurnEnd::Stop("cancelled")); + } + let (tx, rx) = oneshot::channel(); + st.turn = Some(Turn { uuids: vec![uuid], done: Some(tx), cancelled: false, failure: None }); + if !proc.send(&message) { + st.turn = None; + return Err(internal("Claude Code is not running")); + } + rx + }; + Ok(rx.await.unwrap_or_else(|_| TurnEnd::Fail { message: "Claude Code exited.".into(), data: None })) + } + + fn steer(&self, prompt: Value) -> Result<(), String> { + let uuid = new_uuid(true); + let mut message = user_message(&prompt, &uuid, true); + message["priority"] = json!("now"); + let mut st = self.st.lock(); + let proc = st.proc.clone().ok_or("Claude Code is not running")?; + let turn = st.turn.as_mut().filter(|t| !t.cancelled).ok_or("no turn is running")?; + turn.uuids.push(uuid); + if !proc.send(&message) { + return Err("Claude Code is not running".into()); + } + Ok(()) + } + + fn cancel(&self) { + let proc = { + let mut st = self.st.lock(); + match st.turn.as_mut() { + Some(t) => { + t.cancelled = true; + st.proc.clone() + } + None => { + st.cancel_at = Some(Instant::now()); + None + } + } + }; + if let (Some(proc), Some(shim)) = (proc, self.shim()) { + shim.rt.spawn(async move { + if let Err(e) = proc.control(json!({ "subtype": "interrupt", "cancel_queued": true })).await { + tracing::debug!("interrupting claude: {e}"); + } + }); + } + } + + async fn rollback(self: &Arc, turns: usize) -> Result<(), String> { + let shim = self.shim().ok_or("closed")?; + let _guard = self.starting.lock().await; + let (start, pending) = { + let st = self.st.lock(); + if st.turn.is_some() { + return Err("stop the agent first".into()); + } + (st.start.clone(), st.pending) + }; + let base = if pending || !matches!(start, Start::New { .. }) { + start.clone() + } else { + Start::Resume { id: self.id.clone(), at: None } + }; + let chain = shim.base_chain(&self.cwd, &base)?; + let refs: Vec<&Value> = chain.iter().collect(); + let cut = history::cut(&refs, turns)?; + let next = match (&base, cut) { + (Start::Fork { from, .. }, Some(at)) => { + Start::Fork { from: from.clone(), id: self.id.clone(), at: Some(at) } + } + (Start::Fork { .. } | Start::New { .. }, None) => Start::New { id: self.id.clone() }, + (Start::Resume { .. }, Some(at)) => Start::Resume { id: self.id.clone(), at: Some(at) }, + (Start::Resume { .. }, None) => return Err("Claude Code can't drop every turn of a saved session".into()), + (Start::New { .. }, Some(_)) => return Ok(()), + }; + if turns == 0 && next == start { + return Ok(()); + } + shim.save_pending(&self.id, Some(&next)); + { + let mut st = self.st.lock(); + st.pending = true; + st.tr = Translator::new(&self.id, self.cwd.clone(), shim.subagents.load(Ordering::Acquire)); + } + self.launch(next).await.map(|_| ()) + } + + /// The process ended. Unless it was stopped on purpose the client's connection goes + /// too: its next turn starts over and loads the session. + fn on_exit(&self, proc: &Arc) { + let current = self.st.lock().proc.as_ref().is_some_and(|p| Arc::ptr_eq(p, proc)); + if proc.kill.is_cancelled() || !current { + return; + } + let updates = self.st.lock().tr.stop_children(); + let Some(shim) = self.shim() else { return }; + shim.notify(updates); + self.fail_turn("Claude Code exited."); + if !shim.closed.load(Ordering::Acquire) + && let Some(client) = shim.client.get() + { + client.kill(); + } + } + + fn on_cli(self: &Arc, proc: &Arc, msg: Value) { + let kind = msg.get("type").and_then(Value::as_str).unwrap_or_default(); + match kind { + "control_response" => { + let r = msg.get("response").cloned().unwrap_or_default(); + let id = r.get("request_id").and_then(Value::as_str).unwrap_or_default(); + if let Some(tx) = proc.pending.lock().remove(id) { + let _ = tx.send(match r.get("subtype").and_then(Value::as_str) { + Some("success") => Ok(r.get("response").cloned().unwrap_or(Value::Null)), + _ => Err(r.get("error").and_then(Value::as_str).unwrap_or("failed").to_owned()), + }); + } + return; + } + "control_request" => { + let me = self.clone(); + let proc = proc.clone(); + tokio::spawn(async move { me.on_cli_request(&proc, msg).await }); + return; + } + "control_cancel_request" => { + if let Some(t) = + msg.get("request_id").and_then(Value::as_str).and_then(|id| proc.asks.lock().remove(id)) + { + t.cancel(); + } + return; + } + _ => {} + } + let mut updates = Vec::new(); + let mut finished = None; + let mut read_title = false; + { + let mut st = self.st.lock(); + let st = &mut *st; + match kind { + "system" => match msg.get("subtype").and_then(Value::as_str) { + Some("init") => { + st.terminal_only = strings(msg.get("terminal_slash_commands")); + } + Some("status") => { + if let Some(mode) = msg.get("permissionMode").and_then(Value::as_str) + && mode != st.settings.mode + { + st.settings.mode = mode.to_owned(); + updates.push(Update { + session: self.id.clone(), + update: json!({ "sessionUpdate": "current_mode_update", "currentModeId": mode }), + }); + updates.push(Update { + session: self.id.clone(), + update: json!({ "sessionUpdate": "config_option_update", "configOptions": st.settings.options() }), + }); + } + } + Some("commands_changed") => { + st.settings.commands = settings::commands(msg.get("commands"), &st.terminal_only); + updates.push(Update { + session: self.id.clone(), + update: json!({ "sessionUpdate": "available_commands_update", "availableCommands": st.settings.commands }), + }); + } + Some("session_state_changed") if msg.get("state").and_then(Value::as_str) == Some("idle") => { + // A stopped turn whose result never came (stopped before it started). + if st.turn.as_ref().is_some_and(|t| t.cancelled) { + finished = st.turn.take().map(|t| (t, TurnEnd::Stop("cancelled"))); + } + } + _ => updates.extend(st.tr.on_message(&msg)), + }, + "rate_limit_event" => { + let info = msg.get("rate_limit_info").cloned().unwrap_or_default(); + if info.get("status").and_then(Value::as_str) == Some("rejected") { + st.limit_resets_ms = info.get("resetsAt").and_then(Value::as_f64).map(|s| s * 1000.0); + } + } + "command_lifecycle" => { + let uuid = msg.get("command_uuid").and_then(Value::as_str); + let state = msg.get("state").and_then(Value::as_str); + let last = st.turn.as_ref().and_then(|t| t.uuids.last()).map(String::as_str); + if uuid.is_some() && uuid == last && matches!(state, Some("completed" | "cancelled")) { + let stop = if state == Some("cancelled") || st.turn.as_ref().is_some_and(|t| t.cancelled) { + "cancelled" + } else { + "end_turn" + }; + finished = st.turn.take().map(|t| (t, TurnEnd::Stop(stop))); + } + } + "assistant" if msg.get("parent_tool_use_id").is_none_or(Value::is_null) => { + match failure_of(&msg, st.limit_resets_ms) { + Some(f) if st.turn.is_some() => { + if let Some(t) = st.turn.as_mut() { + t.failure = Some(f); + } + } + _ => updates.extend(st.tr.on_message(&msg)), + } + } + "result" => { + updates.extend(st.tr.on_result(&msg)); + if st.pending { + // The CLI wrote the session: it resumes as it is from now on. + st.pending = false; + st.start = Start::Resume { id: self.id.clone(), at: None }; + if let Some(shim) = self.shim() { + shim.save_pending(&self.id, None); + } + } else if matches!(st.start, Start::New { .. }) { + st.start = Start::Resume { id: self.id.clone(), at: None }; + } + read_title = !st.titled; + if let Some(turn) = st.turn.as_ref() + && belongs(turn, &msg) + { + let turn = st.turn.take().unwrap_or_else(|| unreachable!()); + let end = turn_end(&turn, &msg, st.limit_resets_ms); + finished = Some((turn, end)); + } + } + _ => updates.extend(st.tr.on_message(&msg)), + } + } + if let Some(shim) = self.shim() { + shim.notify(updates); + } + if let Some((mut turn, end)) = finished + && let Some(done) = turn.done.take() + { + let _ = done.send(end); + } + if read_title { + self.read_title(); + } + } + + /// Sends the title Claude Code gave the session, once it has one. + fn read_title(self: &Arc) { + let me = self.clone(); + tokio::task::spawn_blocking(move || { + let Some(title) = chain_title(&me) else { return }; + me.st.lock().titled = true; + if let Some(shim) = me.shim() { + shim.notify(vec![Update { + session: me.id.clone(), + update: json!({ "sessionUpdate": "session_info_update", "title": title }), + }]); + } + }); + } + + async fn on_cli_request(self: &Arc, proc: &Arc, msg: Value) { + let id = msg.get("request_id").and_then(Value::as_str).unwrap_or_default().to_owned(); + let request = msg.get("request").cloned().unwrap_or_default(); + let result = match request.get("subtype").and_then(Value::as_str) { + Some("can_use_tool") => Ok(self.can_use_tool(proc, &id, &request).await), + Some(other) => Err(format!("unsupported request {other}")), + None => Err("no subtype".to_owned()), + }; + let response = match result { + Ok(r) => json!({ "subtype": "success", "request_id": id, "response": r }), + Err(e) => json!({ "subtype": "error", "request_id": id, "error": e }), + }; + proc.send(&json!({ "type": "control_response", "response": response })); + } + + /// Asks the client whether a tool may run; answers as a `PermissionResult`. + async fn can_use_tool(self: &Arc, proc: &Arc, request_id: &str, req: &Value) -> Value { + let name = req.get("tool_name").and_then(Value::as_str).unwrap_or_default().to_owned(); + let input = req.get("input").cloned().unwrap_or_else(|| json!({})); + let tool_use = req.get("tool_use_id").and_then(Value::as_str).unwrap_or_default().to_owned(); + let deny = |message: &str| json!({ "behavior": "deny", "message": message, "toolUseID": tool_use }); + let Some(shim) = self.shim() else { return deny("The session closed.") }; + let Some(client) = shim.client.get().cloned() else { return deny("The session closed.") }; + let (session, mut updates) = { + let mut st = self.st.lock(); + let session = st.tr.session_of_agent(req.get("agent_id").and_then(Value::as_str)); + let mut updates = st.tr.ensure_call(&tool_use, &name, &input, &session); + updates.extend(st.tr.child_state(&session, true)); + (session, updates) + }; + let call = self.st.lock().tr.tool_call(&tool_use, &name, &input); + shim.notify(std::mem::take(&mut updates)); + let token = CancellationToken::new(); + proc.asks.lock().insert(request_id.to_owned(), token.clone()); + let ask = |call: Value, options: Value| { + let client = client.clone(); + let token = token.clone(); + let session = session.clone(); + async move { + let r: Result = client + .request( + "session/request_permission", + json!({ "sessionId": session, "toolCall": call, "options": options }), + Some(token), + ) + .await; + r.ok() + .filter(|v| v.pointer("/outcome/outcome").and_then(Value::as_str) == Some("selected")) + .and_then(|v| v.pointer("/outcome/optionId").and_then(Value::as_str).map(str::to_owned)) + } + }; + let answer = if name == "AskUserQuestion" { + ask_questions(&input, &call, ask).await.map_or_else( + || deny("The user didn't answer."), + |answers| { + let mut updated = input.clone(); + updated["answers"] = answers; + json!({ "behavior": "allow", "updatedInput": updated, "toolUseID": tool_use }) + }, + ) + } else { + let options = permission_options(&name); + let chosen = ask(call, options).await; + permission_result(&name, &input, &tool_use, req.get("permission_suggestions"), chosen.as_deref()) + }; + proc.asks.lock().remove(request_id); + let back = self.st.lock().tr.child_state(&session, false); + shim.notify(back.into_iter().collect()); + answer + } +} + +/// The options of a permission request for tool `name`. +fn permission_options(name: &str) -> Value { + if name == "ExitPlanMode" { + return json!([ + { "optionId": "plan-accept-edits", "name": "Yes, and accept edits", "kind": "allow_always" }, + { "optionId": "plan-default", "name": "Yes, and ask before edits", "kind": "allow_once" }, + { "optionId": "reject", "name": "No, keep planning", "kind": "reject_once" }, + ]); + } + json!([ + { "optionId": "allow", "name": "Allow", "kind": "allow_once" }, + { "optionId": "allow-always", "name": "Allow for this session", "kind": "allow_always" }, + { "optionId": "reject", "name": "Reject", "kind": "reject_once" }, + ]) +} + +/// The CLI's `PermissionResult` for the option the user chose (`None`: cancelled). +fn permission_result( + name: &str, + input: &Value, + tool_use: &str, + suggestions: Option<&Value>, + chosen: Option<&str>, +) -> Value { + let allow = |updates: Option| { + let mut r = json!({ "behavior": "allow", "updatedInput": input, "toolUseID": tool_use }); + if let Some(u) = updates { + r["updatedPermissions"] = u; + } + r + }; + let set_mode = |mode: &str| json!([{ "type": "setMode", "mode": mode, "destination": "session" }]); + match chosen { + Some("allow") => allow(None), + Some("allow-always") => { + // What Claude Code suggests remembering, for this session only. + let mut rules: Vec = suggestions.and_then(Value::as_array).cloned().unwrap_or_default(); + for r in &mut rules { + r["destination"] = json!("session"); + } + if rules.is_empty() { + rules.push(json!({ "type": "addRules", "rules": [{ "toolName": name }], "behavior": "allow", "destination": "session" })); + } + allow(Some(Value::Array(rules))) + } + Some("plan-accept-edits") => allow(Some(set_mode("acceptEdits"))), + Some("plan-default") => allow(Some(set_mode("default"))), + Some(_) => json!({ "behavior": "deny", "message": "The user declined this.", "toolUseID": tool_use }), + None => json!({ "behavior": "deny", "message": "The user cancelled this request.", "toolUseID": tool_use }), + } +} + +/// `AskUserQuestion`: each question as a permission request whose options are its +/// answers; `None` when one is not answered. +async fn ask_questions(input: &Value, call: &Value, ask: F) -> Option +where + F: Fn(Value, Value) -> Fut, + Fut: std::future::Future>, +{ + let mut answers = serde_json::Map::new(); + for q in input.get("questions").and_then(Value::as_array).into_iter().flatten() { + let question = q.get("question").and_then(Value::as_str).unwrap_or_default(); + let labels: Vec<&str> = q + .get("options") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|o| o.get("label")?.as_str()) + .collect(); + let mut options: Vec = labels + .iter() + .enumerate() + .map(|(i, l)| json!({ "optionId": format!("answer-{i}"), "name": l, "kind": "allow_once" })) + .collect(); + options.push(json!({ "optionId": "reject", "name": "Skip", "kind": "reject_once" })); + let mut call = call.clone(); + call["title"] = json!(question); + let chosen = ask(call, Value::Array(options)).await?; + let i: usize = chosen.strip_prefix("answer-")?.parse().ok()?; + answers.insert(question.to_owned(), json!(labels.get(i)?)); + } + Some(Value::Object(answers)) +} + +/// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of +/// its messages, or names none (the CLI's own error before the turn began). +fn belongs(turn: &Turn, result: &Value) -> bool { + if result.get("origin").is_some_and(|o| !o.is_null()) { + return false; + } + let mut uuids = strings(result.get("user_message_uuids")); + uuids.extend(result.get("user_message_uuid").and_then(Value::as_str).map(str::to_owned)); + if uuids.is_empty() { + return true; + } + let last = turn.uuids.last(); + uuids.iter().any(|u| Some(u) == last) || turn.cancelled && uuids.iter().any(|u| turn.uuids.contains(u)) +} + +/// How `result` ends `turn`. +fn turn_end(turn: &Turn, result: &Value, limit_resets_ms: Option) -> TurnEnd { + let s = |k: &str| result.get(k).and_then(Value::as_str); + let aborted = s("terminal_reason").is_some_and(|r| r.starts_with("aborted")); + if turn.cancelled || aborted { + return TurnEnd::Stop("cancelled"); + } + if let Some(f) = &turn.failure { + return f.clone(); + } + let status = result.get("api_error_status").and_then(Value::as_i64); + let text = s("result").unwrap_or_default().trim().to_owned(); + if status == Some(429) || USAGE_LIMIT_PREFIXES.iter().any(|p| text.starts_with(p)) { + return usage_limit(if text.is_empty() { "You've hit your usage limit." } else { &text }, limit_resets_ms); + } + if matches!(status, Some(401 | 403)) { + return TurnEnd::Fail { message: format!("Claude Code authentication failed: {text}"), data: None }; + } + let failed = + result.get("is_error").and_then(Value::as_bool) == Some(true) || s("subtype").is_some_and(|t| t != "success"); + if failed { + let errors: Vec = + strings(result.get("errors")).into_iter().filter(|e| !e.starts_with("[ede_diagnostic]")).collect(); + let message = if !text.is_empty() { + text + } else if !errors.is_empty() { + errors.join("\n") + } else { + format!("Claude Code failed ({}).", s("subtype").unwrap_or("error")) + }; + return TurnEnd::Fail { message, data: None }; + } + TurnEnd::Stop(match s("stop_reason") { + Some("max_tokens") => "max_tokens", + Some("refusal") => "refusal", + _ => "end_turn", + }) +} + +/// A main-conversation assistant message that reports a failure instead of answering. +fn failure_of(msg: &Value, limit_resets_ms: Option) -> Option { + let message = msg.get("message")?; + let text: String = message + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + let synthetic = message.get("model").and_then(Value::as_str) == Some(""); + let error = msg.get("error").and_then(Value::as_str); + if error == Some("rate_limit") || synthetic && USAGE_LIMIT_PREFIXES.iter().any(|p| text.starts_with(p)) { + let resets = msg + .pointer("/api_error_params/rate_limit_info/resetsAt") + .and_then(Value::as_f64) + .map(|s| s * 1000.0) + .or(limit_resets_ms); + return Some(usage_limit(if text.is_empty() { "You've hit your usage limit." } else { &text }, resets)); + } + if matches!(error, Some("authentication_failed" | "oauth_org_not_allowed")) + || synthetic && text.contains("Please run /login") + { + return Some(TurnEnd::Fail { message: format!("Claude Code authentication failed: {text}"), data: None }); + } + if let Some(e) = error.filter(|_| synthetic) { + return Some(TurnEnd::Fail { + message: if text.is_empty() { format!("Claude Code failed: {e}") } else { text }, + data: None, + }); + } + None +} + +fn usage_limit(text: &str, resets_ms: Option) -> TurnEnd { + TurnEnd::Fail { + message: text.to_owned(), + data: Some(json!({ "openidea": { "usageLimit": { "resetsAtMs": resets_ms } } })), + } +} + +/// The CLI's user message for ACP content blocks, as the Agent SDK sends a prompt. +fn user_message(prompt: &Value, uuid: &str, steer: bool) -> Value { + let mut content = Vec::new(); + let mut context = Vec::new(); + for block in prompt.as_array().into_iter().flatten() { + let s = |k: &str| block.get(k).and_then(Value::as_str).unwrap_or_default(); + match s("type") { + "text" => content.push(json!({ "type": "text", "text": s("text") })), + "resource_link" => content.push(json!({ "type": "text", "text": link(s("uri")) })), + "resource" => { + let r = block.get("resource").cloned().unwrap_or_default(); + let uri = r.get("uri").and_then(Value::as_str).unwrap_or_default(); + if let Some(text) = r.get("text").and_then(Value::as_str) { + content.push(json!({ "type": "text", "text": link(uri) })); + context.push( + json!({ "type": "text", "text": format!("\n\n{text}\n") }), + ); + } + } + "image" if !s("data").is_empty() => content.push(json!({ + "type": "image", "source": { "type": "base64", "data": s("data"), "media_type": s("mimeType") }, + })), + "image" if s("uri").starts_with("http") => { + content.push(json!({ "type": "image", "source": { "type": "url", "url": s("uri") } })) + } + _ => {} + } + } + content.extend(context); + let _ = steer; + json!({ + "type": "user", + "uuid": uuid, + "session_id": "", + "parent_tool_use_id": Value::Null, + "origin": { "kind": "human" }, + "message": { "role": "user", "content": content }, + }) +} + +/// A file reference as Claude Code's `@` mention. +fn link(uri: &str) -> String { + match uri.strip_prefix("file://") { + Some(path) => format!("[@{}]({uri})", path.rsplit('/').next().unwrap_or(path)), + None => uri.to_owned(), + } +} + +/// A fresh message or session uuid; steers end with [`history::STEER_MARK`], others never. +fn new_uuid(steer: bool) -> String { + loop { + let mut id = uuid::Uuid::new_v4().to_string(); + if steer { + id.replace_range(id.len() - history::STEER_MARK.len().., history::STEER_MARK); + return id; + } + if !id.ends_with(history::STEER_MARK) { + return id; + } + } +} + +fn strings(v: Option<&Value>) -> Vec { + v.and_then(Value::as_array).into_iter().flatten().filter_map(|s| s.as_str().map(str::to_owned)).collect() +} + +/// The title in a session's transcript (or that of the session it continues). +fn chain_title(session: &Session) -> Option { + let shim = session.shim()?; + let path = shim.transcript(&session.cwd, &session.id).filter(|p| p.exists())?; + history::title(&history::read(&path).ok()?) +} + +fn start_json(start: &Start) -> Value { + match start { + Start::New { id } => json!({ "new": id }), + Start::Resume { id, at } => json!({ "resume": id, "at": at }), + Start::Fork { from, id, at } => json!({ "fork": from, "id": id, "at": at }), + } +} + +fn start_from_json(v: &Value) -> Option { + let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned); + if let Some(id) = s("new") { + return Some(Start::New { id }); + } + if let Some(id) = s("resume") { + return Some(Start::Resume { id, at: s("at") }); + } + Some(Start::Fork { from: s("fork")?, id: s("id")?, at: s("at") }) +} diff --git a/crates/ide-agent/src/native/claude/settings.rs b/crates/ide-agent/src/native/claude/settings.rs new file mode 100644 index 000000000..51254dcb4 --- /dev/null +++ b/crates/ide-agent/src/native/claude/settings.rs @@ -0,0 +1,234 @@ +//! A session's settings as ACP config options: model, permission mode and effort, from +//! what Claude Code reports in its `initialize` and `get_settings` responses. + +use serde_json::{Value, json}; + +#[derive(Clone, Debug, Default)] +pub(super) struct Model { + pub value: String, + pub name: String, + pub description: Option, + pub resolved: Option, + pub efforts: Vec, + pub auto_mode: bool, +} + +#[derive(Clone, Debug, Default)] +pub(super) struct Settings { + pub models: Vec, + /// The model option's value. + pub model: String, + /// `default`, or an effort level. + pub effort: String, + pub mode: String, + /// ACP `availableCommands`. + pub commands: Vec, +} + +pub(super) const MODEL: &str = "model"; +pub(super) const MODE: &str = "mode"; +pub(super) const EFFORT: &str = "effort"; + +const MODES: [(&str, &str, &str); 5] = [ + ("default", "Default", "Ask before edits and commands"), + ("acceptEdits", "Accept edits", "Edit files without asking"), + ("plan", "Plan", "Plan before making changes"), + ("auto", "Auto", "Claude decides what needs asking"), + ("bypassPermissions", "Bypass permissions", "Never ask"), +]; + +impl Settings { + /// From the `initialize` response and `get_settings`' `applied` and `effective`. + pub fn from_cli(init: &Value, settings: &Value) -> Self { + let models: Vec = init + .get("models") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|m| { + let s = |k: &str| m.get(k).and_then(Value::as_str).map(str::to_owned); + let value = s("value")?; + Some(Model { + name: s("displayName").unwrap_or_else(|| value.clone()), + description: s("description"), + resolved: s("resolvedModel"), + efforts: if m.get("supportsEffort").and_then(Value::as_bool) == Some(true) { + m.get("supportedEffortLevels") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|l| l.as_str().map(str::to_owned)) + .collect() + } else { + Vec::new() + }, + auto_mode: m.get("supportsAutoMode").and_then(Value::as_bool) == Some(true), + value, + }) + }) + .collect(); + let applied = settings.pointer("/applied/model").and_then(Value::as_str); + let configured = settings.pointer("/effective/model").and_then(Value::as_str); + let mut out = Self { + models, + model: String::new(), + effort: settings.pointer("/applied/effort").and_then(Value::as_str).unwrap_or("default").to_owned(), + mode: init.get("current_permission_mode").and_then(Value::as_str).unwrap_or("default").to_owned(), + commands: commands(init.get("commands"), &[]), + }; + out.model = out.model_value(configured, applied); + out + } + + /// The model option's value for the configured alias and the model it runs. + fn model_value(&mut self, configured: Option<&str>, applied: Option<&str>) -> String { + let runs = |m: &Model| applied.is_none_or(|a| m.value == a || m.resolved.as_deref() == Some(a)); + // The configured alias, unless the session runs another model (`--model`). + if let Some(c) = configured.filter(|c| self.models.iter().any(|m| m.value == *c && runs(m))) { + return c.to_owned(); + } + let Some(applied) = applied else { return self.models.first().map(|m| m.value.clone()).unwrap_or_default() }; + if configured.is_none() && self.models.iter().find(|m| m.value == "default").is_some_and(runs) { + return "default".into(); + } + if let Some(m) = self.models.iter().find(|m| runs(m)) { + return m.value.clone(); + } + self.models.push(Model { value: applied.to_owned(), name: applied.to_owned(), ..Model::default() }); + applied.to_owned() + } + + fn current_model(&self) -> Option<&Model> { + self.models.iter().find(|m| m.value == self.model) + } + + /// After a model change: an effort it doesn't offer goes back to the default. + pub fn set_model(&mut self, value: &str) { + self.model = value.to_owned(); + let efforts = self.current_model().map(|m| m.efforts.clone()).unwrap_or_default(); + if self.effort != "default" && !efforts.contains(&self.effort) { + self.effort = "default".into(); + } + } + + pub fn modes(&self) -> Vec<&'static str> { + let auto = self.current_model().is_some_and(|m| m.auto_mode); + MODES.iter().map(|m| m.0).filter(|m| *m != "auto" || auto).collect() + } + + pub fn options(&self) -> Value { + let mut out = vec![json!({ + "id": MODE, "name": "Mode", "category": "mode", "type": "select", "currentValue": self.mode, + "options": MODES.iter().filter(|m| self.modes().contains(&m.0)) + .map(|(v, n, d)| json!({ "value": v, "name": n, "description": d })).collect::>(), + })]; + if !self.models.is_empty() { + out.push(json!({ + "id": MODEL, "name": "Model", "category": "model", "type": "select", "currentValue": self.model, + "options": self.models.iter().map(|m| { + let mut o = json!({ "value": m.value, "name": m.name }); + if let Some(d) = &m.description { o["description"] = json!(d); } + o + }).collect::>(), + })); + } + let efforts = self.current_model().map(|m| m.efforts.as_slice()).unwrap_or_default(); + if !efforts.is_empty() { + let mut values = vec![json!({ "value": "default", "name": "Default" })]; + values.extend(efforts.iter().map(|e| json!({ "value": e, "name": capitalize(e) }))); + let current = if efforts.contains(&self.effort) { self.effort.as_str() } else { "default" }; + out.push(json!({ + "id": EFFORT, "name": "Effort", "category": "thought_level", "type": "select", + "currentValue": current, "options": values, + })); + } + Value::Array(out) + } + + pub fn valid(&self, option: &str, value: &str) -> bool { + self.options() + .as_array() + .into_iter() + .flatten() + .find(|o| o["id"] == option) + .and_then(|o| o["options"].as_array()) + .is_some_and(|vs| vs.iter().any(|v| v["value"] == value)) + } +} + +/// Slash commands as ACP `availableCommands`, without those that need Claude Code's terminal. +pub(super) fn commands(list: Option<&Value>, terminal_only: &[String]) -> Vec { + list.and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|c| { + let name = c.get("name")?.as_str()?; + if terminal_only.iter().any(|t| t == name) { + return None; + } + let hint = c.get("argumentHint").and_then(Value::as_str).filter(|h| !h.is_empty()); + Some(json!({ + "name": name, + "description": c.get("description").and_then(Value::as_str).unwrap_or_default(), + "input": hint.map(|h| json!({ "hint": h })), + })) + }) + .collect() +} + +fn capitalize(s: &str) -> String { + let mut c = s.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn init() -> Value { + json!({ + "current_permission_mode": "default", + "commands": [{"name": "review", "description": "Review", "argumentHint": "[pr]"}, {"name": "doctor", "description": "", "argumentHint": ""}], + "models": [ + {"value": "default", "resolvedModel": "claude-sonnet-5-5", "displayName": "Default (recommended)", + "supportsEffort": true, "supportedEffortLevels": ["low", "high"], "supportsAutoMode": true}, + {"value": "opus", "resolvedModel": "claude-opus-5-5", "displayName": "Opus", "supportsEffort": true, "supportedEffortLevels": ["low", "max"]}, + {"value": "claude-haiku-4-5", "displayName": "Haiku 4.5"} + ] + }) + } + + #[test] + fn current_model_from_settings() { + let s = Settings::from_cli(&init(), &json!({"applied": {"model": "claude-sonnet-5-5", "effort": "high"}})); + assert_eq!((s.model.as_str(), s.effort.as_str()), ("default", "high")); + let s = Settings::from_cli( + &init(), + &json!({"effective": {"model": "opus"}, "applied": {"model": "claude-opus-5-5"}}), + ); + assert_eq!(s.model, "opus"); + assert_eq!(s.modes(), ["default", "acceptEdits", "plan", "bypassPermissions"]); + let flag = Settings::from_cli( + &init(), + &json!({"effective": {"model": "opus"}, "applied": {"model": "claude-sonnet-5-5"}}), + ); + assert_eq!(flag.model, "default", "`--model` wins over the settings file"); + let s = Settings::from_cli(&init(), &json!({"applied": {"model": "claude-x-1"}})); + assert_eq!(s.model, "claude-x-1"); + } + + #[test] + fn options_follow_the_model() { + let mut s = Settings::from_cli(&init(), &json!({"applied": {"model": "claude-sonnet-5-5", "effort": "high"}})); + let o = s.options(); + assert_eq!(o[0]["options"].as_array().unwrap().len(), 5); + assert_eq!(o[2]["currentValue"], "high"); + assert!(s.valid("effort", "low") && !s.valid("effort", "max")); + s.set_model("opus"); + assert_eq!(s.effort, "default"); + s.set_model("claude-haiku-4-5"); + assert_eq!(s.options().as_array().unwrap().len(), 2, "no effort option without levels"); + let cmds = commands(init().get("commands"), &["doctor".into()]); + assert_eq!(cmds, vec![json!({"name": "review", "description": "Review", "input": {"hint": "[pr]"}})]); + } +} diff --git a/crates/ide-agent/src/native/claude/tests.rs b/crates/ide-agent/src/native/claude/tests.rs new file mode 100644 index 000000000..8faf50db7 --- /dev/null +++ b/crates/ide-agent/src/native/claude/tests.rs @@ -0,0 +1,1003 @@ +//! The shim against recorded Claude Code sessions, and its pure translations. +//! +//! Fixtures under `tests/fixtures/claude/` hold what went over the CLI's stdio in a real +//! session driven through the shim: `{"launch": [args]}` starts a process, then +//! `{"from": "shim"|"cli", "msg": …}` frames in order. Replaying plays the CLI's side +//! back, waiting for each frame the shim sends before it sends what followed it. +//! Re-record with `cargo test -p ide-agent record_claude_fixtures -- --ignored` (needs a +//! signed-in `claude`; uses Haiku). + +use super::*; +use std::collections::VecDeque; +use std::future::Future; +use std::pin::Pin; +use tokio::io::AsyncBufReadExt; + +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/claude"); +const WAIT: Duration = Duration::from_secs(20); + +// ---------- the client side ---------- + +#[derive(Default)] +struct Seen { + /// `(sessionId, update)`. + updates: Vec<(String, Value)>, + /// `session/request_permission` params. + asked: Vec, +} + +struct TestClient { + seen: Arc>, + /// Option ids to choose, in order (`None`: cancel). + answers: Arc>>>, +} + +impl Handler for TestClient { + fn on_notification(&self, method: &str, params: Value) { + if method == "session/update" { + let session = params["sessionId"].as_str().unwrap_or_default().to_owned(); + self.seen.lock().updates.push((session, params["update"].clone())); + } + } + + fn on_request(&self, method: &str, params: Value) -> Result { + assert_eq!(method, "session/request_permission"); + self.seen.lock().asked.push(params); + Ok(match self.answers.lock().pop_front().flatten() { + Some(id) => json!({ "outcome": { "outcome": "selected", "optionId": id } }), + None => json!({ "outcome": { "outcome": "cancelled" } }), + }) + } +} + +struct Harness { + cli: Arc, + config_dir: PathBuf, + pending_dir: PathBuf, + cwd: PathBuf, + args: Vec, + client: Peer, + seen: Arc>, + answers: Arc>>>, + /// Against the real CLI. + recording: bool, +} + +impl Harness { + async fn connect( + cli: Arc, + config_dir: PathBuf, + pending_dir: PathBuf, + cwd: PathBuf, + args: Vec, + ) -> Self { + let seen = Arc::new(Mutex::new(Seen::default())); + let answers = Arc::new(Mutex::new(VecDeque::new())); + let config = Config { + program: "claude".into(), + args: args.clone(), + env: Vec::new(), + config_dir: Some(config_dir.clone()), + pending_dir: pending_dir.clone(), + cwd: cwd.clone(), + }; + let shim = Shim::new(config, cli.clone(), Arc::new(|line| eprintln!("[claude] {line}"))); + let client = super::super::serve(shim, Arc::new(TestClient { seen: seen.clone(), answers: answers.clone() })); + let init: Value = client + .request("initialize", json!({ "protocolVersion": 1, "clientCapabilities": { "subagents": {} } }), None) + .await + .unwrap(); + assert_eq!(init["agentCapabilities"]["loadSession"], true); + Self { cli, config_dir, pending_dir, cwd, args, client, seen, answers, recording: false } + } + + /// A new connection to the same CLI and directories (the app restarted). + async fn reconnect(&self) -> Self { + self.client.kill(); + let h = Self::connect( + self.cli.clone(), + self.config_dir.clone(), + self.pending_dir.clone(), + self.cwd.clone(), + self.args.clone(), + ) + .await; + Self { recording: self.recording, ..h } + } + + async fn request(&self, method: &str, params: Value) -> Result { + tokio::time::timeout(Duration::from_secs(180), self.client.request(method, params, None)) + .await + .unwrap_or(Err(RpcError::Timeout)) + } + + async fn new_session(&self) -> String { + let r = self.request("session/new", json!({ "cwd": self.cwd, "mcpServers": [] })).await.unwrap(); + r["sessionId"].as_str().unwrap().to_owned() + } + + async fn prompt(&self, session: &str, text: &str) -> Result { + self.request("session/prompt", json!({ "sessionId": session, "prompt": [{ "type": "text", "text": text }] })) + .await + } + + fn answer(&self, answers: &[Option<&'static str>]) { + self.answers.lock().extend(answers.iter().copied()); + } + + /// Waits for the trailing updates of a turn (they are delivered on their own thread). + async fn settle(&self) { + tokio::time::sleep(Duration::from_millis(150)).await; + } + + fn updates(&self, session: &str, kind: &str) -> Vec { + self.seen + .lock() + .updates + .iter() + .filter(|(s, u)| s == session && u["sessionUpdate"] == kind) + .map(|(_, u)| u.clone()) + .collect() + } + + fn text(&self, session: &str, kind: &str) -> String { + self.updates(session, kind).iter().filter_map(|u| u["content"]["text"].as_str()).collect() + } +} + +// ---------- scenarios: recorded against the real CLI, replayed in tests ---------- + +type Outcome = Vec<(String, Result)>; + +fn outcome(label: &str, r: Result) -> (String, Result) { + let r = r.map_err(|e| match e { + RpcError::Server { message, data, .. } => format!("{message} {}", data.unwrap_or_default()), + e => e.to_string(), + }); + (label.to_owned(), r) +} + +async fn simple(h: &Harness) -> (Outcome, String) { + let s = h.new_session().await; + let r = h.prompt(&s, "Reply with exactly: hello there").await; + (vec![outcome("prompt", r)], s) +} + +async fn multi_turn(h: &Harness) -> (Outcome, String) { + let s = h.new_session().await; + let a = h.prompt(&s, "Remember the word PEACH. Reply with just OK.").await; + let b = h.prompt(&s, "Which word did I ask you to remember? Reply with just the word.").await; + (vec![outcome("first", a), outcome("second", b)], s) +} + +async fn tool_approval(h: &Harness) -> (Outcome, String) { + let s = h.new_session().await; + h.answer(&[Some("allow"), Some("reject")]); + let r = h + .prompt( + &s, + "Use the Write tool to create notes.txt containing exactly 'one'. Then use the Edit tool to \ + replace 'one' with 'two' in it. One tool at a time, no other tools. If a tool is refused, stop \ + and say so in one sentence.", + ) + .await; + (vec![outcome("prompt", r)], s) +} + +async fn interrupt(h: &Harness) -> (Outcome, String) { + let s = h.new_session().await; + let prompt = h.prompt(&s, "Count from 1 to 300, one number per line."); + let cancel = async { + tokio::time::sleep(Duration::from_millis(30)).await; + h.client.notify("session/cancel", json!({ "sessionId": s })).unwrap(); + }; + let (r, ()) = tokio::join!(prompt, cancel); + // The session still works. + let again = h.prompt(&s, "Reply with exactly: still here").await; + (vec![outcome("cancelled", r), outcome("again", again)], s) +} + +async fn steer(h: &Harness) -> (Outcome, String) { + let s = h.new_session().await; + let prompt = h.prompt(&s, "Run the Bash command `sleep 3 && echo done`, then tell me its output in one line."); + let steer = async { + tokio::time::sleep(Duration::from_millis(2500)).await; + h.request( + "_openidea/steer", + json!({ "sessionId": s, "prompt": [{ "type": "text", "text": "Also end your answer with the word BANANA." }] }), + ) + .await + }; + let (r, steered) = tokio::join!(prompt, steer); + (vec![outcome("prompt", r), outcome("steer", steered)], s) +} + +async fn subagent(h: &Harness) -> (Outcome, String) { + let s = h.new_session().await; + h.answer(&[Some("allow"), Some("allow")]); + let r = h + .prompt( + &s, + "Use the Agent tool exactly once (subagent_type general-purpose, model haiku, run_in_background false) \ + with the prompt: 'Run the Bash command `echo sub` and reply with its output only.' Then tell me \ + what it said.", + ) + .await; + if h.recording { + // A background subagent reports after the turn. + tokio::time::sleep(Duration::from_secs(8)).await; + } + (vec![outcome("prompt", r)], s) +} + +/// Prompts, then loads the session in a new connection and prompts again. +async fn resume(h: &Harness) -> (Outcome, String) { + let s = h.new_session().await; + let a = h.prompt(&s, "Remember the word MANGO. Reply with just OK.").await; + h.settle().await; + let h2 = h.reconnect().await; + on_reload(&h2, &s); + let loaded = h2.request("session/load", json!({ "sessionId": s, "cwd": h2.cwd, "mcpServers": [] })).await; + let b = h2.prompt(&s, "Which word did I ask you to remember? Reply with just the word.").await; + h2.settle().await; + // The second connection's updates, for the assertions. + *h.seen.lock() = std::mem::take(&mut *h2.seen.lock()); + (vec![outcome("first", a), outcome("load", loaded), outcome("second", b)], s) +} + +type ScenarioFn = for<'a> fn(&'a Harness) -> Pin + Send + 'a>>; + +const SCENARIOS: &[(&str, ScenarioFn)] = &[ + ("simple", |h| Box::pin(simple(h))), + ("multi_turn", |h| Box::pin(multi_turn(h))), + ("tool_approval", |h| Box::pin(tool_approval(h))), + ("interrupt", |h| Box::pin(interrupt(h))), + ("steer", |h| Box::pin(steer(h))), + ("subagent", |h| Box::pin(subagent(h))), + ("resume", |h| Box::pin(resume(h))), +]; + +fn scenario(name: &str) -> ScenarioFn { + SCENARIOS.iter().find(|(n, _)| *n == name).unwrap().1 +} + +/// Before a reload in a replay: the session's transcript, as it was recorded. +fn on_reload(h: &Harness, session: &str) { + let fixture = Path::new(FIXTURES).join("resume.transcript.jsonl"); + if !h.recording && fixture.exists() { + let dir = h.config_dir.join("projects").join(history::escape(&h.cwd)); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::copy(fixture, dir.join(format!("{session}.jsonl"))).unwrap(); + } +} + +// ---------- replaying ---------- + +#[derive(Clone)] +enum Frame { + Launch(Vec), + Shim(Value), + Cli(Value), +} + +fn load_fixture(name: &str) -> Vec> { + let path = Path::new(FIXTURES).join(format!("{name}.jsonl")); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let mut out: Vec> = Vec::new(); + for line in text.lines().filter(|l| !l.trim().is_empty()) { + let v: Value = serde_json::from_str(line).unwrap(); + if let Some(args) = v.get("launch") { + out.push(vec![Frame::Launch(strings(Some(args)))]); + } else if let Some(last) = out.last_mut() { + last.push(match v["from"].as_str() { + Some("shim") => Frame::Shim(v["msg"].clone()), + _ => Frame::Cli(v["msg"].clone()), + }); + } + } + out +} + +/// What a frame is, for matching the shim's frames to the recorded ones. +fn frame_kind(m: &Value) -> String { + let sub = m.pointer("/request/subtype").and_then(Value::as_str).unwrap_or_default(); + format!("{}/{sub}", m["type"].as_str().unwrap_or_default()) +} + +/// Plays the CLI's side of recorded processes. +struct Replay { + processes: Mutex>>, + /// What the shim sent, per process. + sent: Arc>>>, + launched: Mutex>>, + errors: Arc>>, +} + +impl Cli for Replay { + fn launch(&self, launch: &Launch, _: OnStderr) -> std::io::Result { + let mut frames = + self.processes.lock().pop_front().ok_or_else(|| std::io::Error::other("no more recorded processes"))?; + if let Some(Frame::Launch(recorded)) = frames.first() { + // The same flags (the recording passed `--model haiku`; ids differ). + let flags = |args: &[String]| { + args.iter().filter(|a| a.starts_with("--") && *a != "--model").cloned().collect::>() + }; + if flags(recorded) != flags(&launch.args) { + self.errors.lock().push(format!("launched with {:?}, recorded {recorded:?}", launch.args)); + } + frames.remove(0); + } + self.launched.lock().push(launch.args.clone()); + let (shim_in, cli_in) = tokio::io::duplex(1 << 20); + let (mut cli_out, shim_out) = tokio::io::duplex(1 << 20); + let kill = CancellationToken::new(); + let (sent, errors, token) = (self.sent.clone(), self.errors.clone(), kill.clone()); + sent.lock().push(Vec::new()); + tokio::spawn(async move { + let mut lines = BufReader::new(cli_in).lines(); + let mut subs: Vec<(String, String)> = Vec::new(); + for frame in frames { + match frame { + Frame::Launch(_) => {} + Frame::Cli(msg) => { + let mut line = msg.to_string(); + for (from, to) in &subs { + line = line.replace(from.as_str(), to); + } + if cli_out.write_all(format!("{line}\n").as_bytes()).await.is_err() { + return; + } + } + Frame::Shim(expected) => { + let got = tokio::select! { + l = tokio::time::timeout(WAIT, lines.next_line()) => l, + _ = token.cancelled() => return, + }; + let Ok(Ok(Some(line))) = got else { + errors.lock().push(format!("the shim never sent {}", frame_kind(&expected))); + return; + }; + let got: Value = serde_json::from_str(&line).unwrap(); + if frame_kind(&got) != frame_kind(&expected) { + errors.lock().push(format!("expected {}, the shim sent {line}", frame_kind(&expected))); + } + for key in ["/request_id", "/uuid"] { + if let (Some(from), Some(to)) = ( + expected.pointer(key).and_then(Value::as_str), + got.pointer(key).and_then(Value::as_str), + ) && from != to + { + subs.push((from.to_owned(), to.to_owned())); + } + } + sent.lock().last_mut().unwrap().push(got); + } + } + } + // The recorded process lived on until it was stopped. + loop { + tokio::select! { + l = lines.next_line() => match l { + Ok(Some(line)) => { + if let Ok(v) = serde_json::from_str::(&line) { + sent.lock().last_mut().unwrap().push(v); + } + } + _ => break, + }, + _ = token.cancelled() => break, + } + } + token.cancelled().await; + drop(cli_out); + }); + Ok(cli::CliIo { stdin: Box::pin(shim_in), stdout: Box::pin(shim_out), kill }) + } +} + +struct Replayed { + h: Harness, + outcome: Outcome, + session: String, + cli: Arc, + _dirs: (tempfile::TempDir, tempfile::TempDir), +} + +impl Replayed { + fn result(&self, label: &str) -> &Result { + &self.outcome.iter().find(|(l, _)| l == label).unwrap().1 + } + + fn stop(&self, label: &str) -> String { + match self.result(label) { + Ok(v) => v["stopReason"].as_str().unwrap_or_default().to_owned(), + Err(e) => panic!("{label} failed: {e}"), + } + } + + /// The frames the shim sent to process `n`. + fn sent(&self, n: usize) -> Vec { + self.cli.sent.lock()[n].clone() + } +} + +async fn replay(name: &str) -> Replayed { + let processes = load_fixture(name); + let cli = Arc::new(Replay { + processes: Mutex::new(processes.into()), + sent: Arc::default(), + launched: Mutex::default(), + errors: Arc::default(), + }); + let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let cwd_path = std::fs::canonicalize(cwd.path()).unwrap(); + let h = + Harness::connect(cli.clone(), config.path().into(), config.path().join("pending"), cwd_path, Vec::new()).await; + let (outcome, session) = scenario(name)(&h).await; + h.settle().await; + assert!(cli.errors.lock().is_empty(), "{name}: {:?}", cli.errors.lock()); + Replayed { h, outcome, session, cli, _dirs: (config, cwd) } +} + +#[tokio::test(flavor = "multi_thread")] +async fn replay_simple() { + let r = replay("simple").await; + assert_eq!(r.stop("prompt"), "end_turn"); + assert!(r.h.text(&r.session, "agent_message_chunk").to_lowercase().contains("hello there")); + // Streamed as it came, and not again from the whole message. + assert!( + r.h.updates(&r.session, "agent_message_chunk").len() > 1 + || r.h.text(&r.session, "agent_message_chunk").len() < 20 + ); + let usage = r.h.updates(&r.session, "usage_update"); + assert!(usage.last().is_some_and(|u| u["used"].as_u64() > Some(0) && u["size"].as_u64() > Some(0)), "{usage:?}"); + let commands = r.h.updates(&r.session, "available_commands_update"); + assert!(commands.iter().any(|c| c["availableCommands"].as_array().is_some_and(|a| !a.is_empty()))); + // The process ran as the Agent SDK runs it, with the session's id. + let args = r.cli.launched.lock()[0].clone(); + assert!(args.windows(2).any(|w| w == ["--session-id", r.session.as_str()]), "{args:?}"); + assert!(args.contains(&"--permission-prompt-tool".to_owned())); + let user = r.sent(0).into_iter().find(|m| m["type"] == "user").unwrap(); + assert_eq!(user["message"]["content"][0]["text"], "Reply with exactly: hello there"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn replay_multi_turn() { + let r = replay("multi_turn").await; + assert_eq!((r.stop("first"), r.stop("second")), ("end_turn".into(), "end_turn".into())); + assert!(r.h.text(&r.session, "agent_message_chunk").contains("PEACH")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn replay_tool_approval() { + let r = replay("tool_approval").await; + assert_eq!(r.stop("prompt"), "end_turn"); + let asked = r.h.seen.lock().asked.clone(); + assert_eq!(asked.len(), 2, "{asked:?}"); + assert_eq!(asked[0]["toolCall"]["kind"], "edit"); + assert!(asked[0]["toolCall"]["title"].as_str().unwrap().starts_with("Write")); + let kinds: Vec<&str> = + asked[0]["options"].as_array().unwrap().iter().map(|o| o["kind"].as_str().unwrap()).collect(); + assert_eq!(kinds, ["allow_once", "allow_always", "reject_once"]); + let calls = r.h.updates(&r.session, "tool_call"); + let write = calls.iter().find(|c| c["title"].as_str().is_some_and(|t| t.starts_with("Write"))).unwrap(); + let id = write["toolCallId"].as_str().unwrap(); + let done: Vec = + r.h.updates(&r.session, "tool_call_update").into_iter().filter(|u| u["toolCallId"] == id).collect(); + assert!(done.iter().any(|u| u["status"] == "completed" + && u["content"][0]["type"] == "diff" + && u["content"][0]["newText"] == "one")); + let edit = + r.h.updates(&r.session, "tool_call_update") + .into_iter() + .find(|u| u["status"] == "failed" && u["toolCallId"] != id); + assert!(edit.is_some(), "the refused edit failed"); + // The CLI got an allow, then a deny. + let answers: Vec = r + .sent(0) + .into_iter() + .filter(|m| m["type"] == "control_response") + .map(|m| m["response"]["response"]["behavior"].clone()) + .collect(); + assert_eq!(answers, [json!("allow"), json!("deny")]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn replay_interrupt() { + let r = replay("interrupt").await; + assert_eq!(r.stop("cancelled"), "cancelled"); + assert_eq!(r.stop("again"), "end_turn"); + assert!(r.sent(0).iter().any(|m| m["request"]["subtype"] == "interrupt")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn replay_steer() { + let r = replay("steer").await; + assert!(r.result("steer").is_ok(), "{:?}", r.result("steer")); + assert_eq!(r.stop("prompt"), "end_turn"); + assert!(r.h.text(&r.session, "agent_message_chunk").contains("BANANA")); + let steer = r.sent(0).into_iter().filter(|m| m["type"] == "user").nth(1).unwrap(); + assert_eq!(steer["priority"], "now"); + assert!(steer["uuid"].as_str().unwrap().ends_with(history::STEER_MARK)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn replay_subagent() { + let r = replay("subagent").await; + assert_eq!(r.stop("prompt"), "end_turn"); + let subs = r.h.updates(&r.session, "subagent_update"); + let child = subs.first().expect("a subagent")["sessionId"].as_str().unwrap().to_owned(); + assert_eq!(subs[0]["state"]["state"], "running"); + assert!(subs.iter().any(|u| u["state"]["state"] == "idle" && u["state"]["stopReason"] == "end_turn"), "{subs:?}"); + let messages = r.h.updates(&r.session, "session_message"); + assert!(messages.iter().any(|m| m["recipientSessionId"] == child)); + let answer = messages.iter().find(|m| m["senderSessionId"] == child).expect("the answer"); + assert_eq!(answer["content"][0]["text"], "sub"); + // Its own work is in its own session. + assert!(!r.h.updates(&child, "tool_call").is_empty() || !r.h.updates(&child, "agent_message_chunk").is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn replay_resume() { + let r = replay("resume").await; + assert_eq!(r.stop("first"), "end_turn"); + assert!(r.result("load").is_ok(), "{:?}", r.result("load")); + assert_eq!(r.stop("second"), "end_turn"); + // History came back before the load answered, the prompt included. + assert!(r.h.text(&r.session, "user_message_chunk").contains("MANGO")); + assert!(r.h.text(&r.session, "agent_message_chunk").contains("MANGO")); + let args = r.cli.launched.lock()[1].clone(); + assert!(args.windows(2).any(|w| w == ["--resume", r.session.as_str()]), "{args:?}"); +} + +// ---------- recording ---------- + +/// Passes the real CLI's stdio through, keeping what went over it. +struct Recorder { + log: Arc>>, +} + +impl Cli for Recorder { + fn launch(&self, launch: &Launch, on_stderr: OnStderr) -> std::io::Result { + let real = cli::Process.launch(launch, on_stderr)?; + self.log.lock().push(json!({ "launch": launch.args })); + let (shim_in, from_shim) = tokio::io::duplex(1 << 20); + let (mut to_shim, shim_out) = tokio::io::duplex(1 << 20); + let (log, mut stdin) = (self.log.clone(), real.stdin); + tokio::spawn(async move { + let mut lines = BufReader::new(from_shim).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Ok(v) = serde_json::from_str::(&line) { + log.lock().push(json!({ "from": "shim", "msg": v })); + } + if stdin.write_all(format!("{line}\n").as_bytes()).await.is_err() { + break; + } + let _ = stdin.flush().await; + } + }); + let log = self.log.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(real.stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Ok(v) = serde_json::from_str::(&line) { + log.lock().push(json!({ "from": "cli", "msg": v })); + } + if to_shim.write_all(format!("{line}\n").as_bytes()).await.is_err() { + break; + } + } + }); + Ok(cli::CliIo { stdin: Box::pin(shim_in), stdout: Box::pin(shim_out), kill: real.kill }) + } +} + +/// Drops what is private or only noise: the account, settings, hooks, the user's skills. +fn scrub(frame: &mut Value) -> bool { + let Some(msg) = frame.get_mut("msg") else { return true }; + let sub = msg.get("subtype").and_then(Value::as_str).unwrap_or_default().to_owned(); + if msg["type"] == "system" && sub.starts_with("hook_") { + return false; + } + if msg["type"] == "system" && sub == "init" { + for k in ["tools", "mcp_servers", "slash_commands", "skills", "plugins", "agents", "messaging_socket_path"] { + if let Some(o) = msg.as_object_mut() { + o.remove(k); + } + } + } + if msg["type"] == "system" && sub == "commands_changed" { + msg["commands"] = json!([{ "name": "review", "description": "Review a pull request", "argumentHint": "" }]); + } + if let Some(r) = msg.pointer_mut("/response/response") + && let Some(o) = r.as_object_mut() + { + if o.contains_key("commands") { + o.insert( + "commands".into(), + json!([{ "name": "review", "description": "Review a pull request", "argumentHint": "" }, + { "name": "init", "description": "Write a CLAUDE.md", "argumentHint": "" }]), + ); + for k in ["agents", "account", "available_output_styles", "user_output_styles_dir", "output_style"] { + o.remove(k); + } + } + if let Some(applied) = o.get("applied").cloned() { + let model = o.get("effective").and_then(|e| e.get("model")).cloned(); + o.clear(); + o.insert("applied".into(), applied); + o.insert("effective".into(), json!({ "model": model })); + } + } + strip(msg); + true +} + +/// Signatures, request ids and the like. +fn strip(v: &mut Value) { + match v { + Value::Object(o) => { + o.remove("signature"); + for (_, x) in o.iter_mut() { + strip(x); + } + } + Value::Array(a) => a.iter_mut().for_each(strip), + Value::String(s) => { + if let Some(home) = std::env::var_os("HOME").map(|h| h.to_string_lossy().into_owned()) + && s.contains(&home) + { + *s = s.replace(&home, "/home/user"); + } + } + _ => {} + } +} + +fn scrub_transcript(path: &Path) -> String { + let entries = history::read(path).unwrap(); + let mut out = String::new(); + for mut e in entries { + if !matches!(e["type"].as_str(), Some("user" | "assistant" | "ai-title")) { + // The chain still needs every link. + let Some(o) = e.as_object() else { continue }; + if !o.contains_key("uuid") { + continue; + } + e = json!({ "type": e["type"], "uuid": e["uuid"], "parentUuid": e["parentUuid"], "isSidechain": e["isSidechain"] }); + } + strip(&mut e); + out.push_str(&e.to_string()); + out.push('\n'); + } + out +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "drives the real claude CLI (signed in); rewrites the fixtures"] +async fn record_claude_fixtures() { + let only = std::env::var("CLAUDE_SCENARIO").ok(); + std::fs::create_dir_all(FIXTURES).unwrap(); + for (name, run) in SCENARIOS { + if only.as_deref().is_some_and(|o| o != *name) { + continue; + } + let log = Arc::new(Mutex::new(Vec::new())); + let cwd = tempfile::tempdir().unwrap(); + let pending = tempfile::tempdir().unwrap(); + let cwd_path = std::fs::canonicalize(cwd.path()).unwrap(); + let config_dir = cli::config_dir(&[]).unwrap(); + let h = Harness::connect( + Arc::new(Recorder { log: log.clone() }), + config_dir.clone(), + pending.path().into(), + cwd_path.clone(), + vec!["--model".into(), "haiku".into()], + ) + .await; + let h = Harness { recording: true, ..h }; + let (outcome, session) = run(&h).await; + h.settle().await; + h.client.kill(); + tokio::time::sleep(Duration::from_millis(500)).await; + eprintln!("{name}: {outcome:?}"); + let mut text = String::new(); + for mut frame in log.lock().clone() { + if scrub(&mut frame) { + text.push_str(&frame.to_string()); + text.push('\n'); + } + } + std::fs::write(Path::new(FIXTURES).join(format!("{name}.jsonl")), text).unwrap(); + if *name == "resume" { + let path = history::transcript(&config_dir, &cwd_path, &session); + std::fs::write(Path::new(FIXTURES).join("resume.transcript.jsonl"), scrub_transcript(&path)).unwrap(); + } + } +} + +/// A stop that reaches the shim before its prompt (they race) still stops the turn. +#[tokio::test(flavor = "multi_thread")] +async fn a_stop_ahead_of_its_prompt_cancels_it() { + // The recorded start of a session, and nothing more. + let mut process = load_fixture("simple").remove(0); + process.truncate(5); + let cli = Arc::new(Replay { + processes: Mutex::new(VecDeque::from([process])), + sent: Arc::default(), + launched: Mutex::default(), + errors: Arc::default(), + }); + let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let h = Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await; + let s = h.new_session().await; + h.client.notify("session/cancel", json!({ "sessionId": s })).unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + let r = h.prompt(&s, "hi").await.unwrap(); + assert_eq!(r["stopReason"], "cancelled"); + assert!(cli.sent.lock()[0].iter().all(|m| m["type"] != "user"), "nothing was sent"); +} + +/// Fork and undo against the real CLI, across connections, in a symlinked directory +/// (macOS `/tmp`). +#[tokio::test(flavor = "multi_thread")] +#[ignore = "drives the real claude CLI (signed in)"] +async fn live_fork_and_rollback() { + let base = tempfile::Builder::new().prefix("openidea-claude-").tempdir_in("/tmp").unwrap(); + let cwd = base.path().to_path_buf(); // /tmp/…: a symlink on macOS + let pending = tempfile::tempdir().unwrap(); + let config_dir = cli::config_dir(&[]).unwrap(); + let args = vec!["--model".to_owned(), "haiku".to_owned()]; + let connect = || { + Harness::connect(Arc::new(cli::Process), config_dir.clone(), pending.path().into(), cwd.clone(), args.clone()) + }; + let words = "List every word I asked you to remember, comma separated, uppercase, nothing else."; + let last = |h: &Harness, s: &str| h.text(s, "agent_message_chunk"); + + let h = connect().await; + let s = h.new_session().await; + h.prompt(&s, "Remember the word APPLE. Reply with just OK.").await.unwrap(); + h.prompt(&s, "Remember the word BOAT. Reply with just OK.").await.unwrap(); + let fork: Value = h.request("session/fork", json!({ "sessionId": s, "cwd": cwd, "mcpServers": [] })).await.unwrap(); + let f = fork["sessionId"].as_str().unwrap().to_owned(); + assert_ne!(f, s); + + // The copy, loaded by a fresh connection, its last turn dropped. + let h2 = connect().await; + h2.request("session/load", json!({ "sessionId": f, "cwd": cwd, "mcpServers": [] })).await.unwrap(); + assert!(h2.text(&f, "user_message_chunk").contains("BOAT"), "history replayed"); + h2.request("_openidea/rollback", json!({ "sessionId": f, "turns": 1 })).await.unwrap(); + h2.prompt(&f, words).await.unwrap(); + h2.settle().await; + let answer = last(&h2, &f); + assert!(answer.contains("APPLE") && !answer.contains("BOAT"), "fork: {answer}"); + + // The original still has both. + h.prompt(&s, words).await.unwrap(); + h.settle().await; + assert!(last(&h, &s).contains("BOAT"), "original: {}", last(&h, &s)); + + // Undo in the original, then reload it elsewhere before prompting: the undo holds. + h.request("_openidea/rollback", json!({ "sessionId": s, "turns": 2 })).await.unwrap(); + let h3 = connect().await; + h3.request("session/load", json!({ "sessionId": s, "cwd": cwd, "mcpServers": [] })).await.unwrap(); + assert!(!h3.text(&s, "user_message_chunk").contains("List every"), "the undone turns are gone"); + h3.prompt(&s, words).await.unwrap(); + h3.settle().await; + let answer = last(&h3, &s); + assert!(answer.contains("APPLE") && !answer.contains("BOAT"), "rolled back: {answer}"); +} + +// ---------- pure translations ---------- + +#[test] +fn prompts_become_user_messages() { + let m = user_message( + &json!([ + {"type": "text", "text": "look"}, + {"type": "resource", "resource": {"uri": "file:///p/a.rs", "text": "fn a() {}"}}, + {"type": "resource_link", "uri": "file:///p/b.rs", "name": "b.rs"}, + {"type": "image", "mimeType": "image/png", "data": "AAA"} + ]), + "u1", + false, + ); + assert_eq!(m["uuid"], "u1"); + assert_eq!(m["origin"], json!({"kind": "human"})); + assert_eq!( + m["message"]["content"], + json!([ + {"type": "text", "text": "look"}, + {"type": "text", "text": "[@a.rs](file:///p/a.rs)"}, + {"type": "text", "text": "[@b.rs](file:///p/b.rs)"}, + {"type": "image", "source": {"type": "base64", "data": "AAA", "media_type": "image/png"}}, + {"type": "text", "text": "\n\nfn a() {}\n"} + ]) + ); + assert!(new_uuid(true).ends_with(history::STEER_MARK)); + assert!(!new_uuid(false).ends_with(history::STEER_MARK)); +} + +fn turn(uuids: &[&str], cancelled: bool) -> Turn { + Turn { uuids: uuids.iter().map(|u| (*u).to_owned()).collect(), done: None, cancelled, failure: None } +} + +#[test] +fn which_result_ends_a_turn() { + let t = turn(&["a", "b"], false); + assert!(belongs(&t, &json!({"user_message_uuids": ["b"]}))); + assert!(!belongs(&t, &json!({"user_message_uuids": ["a"]})), "a steer superseded it"); + assert!(belongs(&t, &json!({"subtype": "error_during_execution"})), "names no message"); + assert!(!belongs(&t, &json!({"origin": {"kind": "task-notification"}})), "Claude Code's own turn"); + assert!(belongs(&turn(&["a", "b"], true), &json!({"user_message_uuids": ["a"]}))); +} + +#[test] +fn how_results_end_turns() { + let t = turn(&["a"], false); + let end = |r: Value| turn_end(&t, &r, Some(1.5e12)); + assert_eq!(end(json!({"subtype": "success", "stop_reason": "end_turn"})), TurnEnd::Stop("end_turn")); + assert_eq!(end(json!({"subtype": "success", "stop_reason": "max_tokens"})), TurnEnd::Stop("max_tokens")); + assert_eq!( + end(json!({"subtype": "error_during_execution", "is_error": true, "terminal_reason": "aborted_streaming"})), + TurnEnd::Stop("cancelled") + ); + assert_eq!(turn_end(&turn(&["a"], true), &json!({"subtype": "success"}), None), TurnEnd::Stop("cancelled")); + assert_eq!( + end( + json!({"subtype": "success", "is_error": true, "api_error_status": 429, "result": "You've hit your limit · resets 3pm"}) + ), + TurnEnd::Fail { + message: "You've hit your limit · resets 3pm".into(), + data: Some(json!({"openidea": {"usageLimit": {"resetsAtMs": 1.5e12}}})), + } + ); + let TurnEnd::Fail { message, .. } = + end(json!({"is_error": true, "api_error_status": 403, "result": "API Error: 403"})) + else { + panic!() + }; + assert!(message.contains("authentication")); + let TurnEnd::Fail { message, .. } = + end(json!({"subtype": "error_during_execution", "is_error": true, "errors": ["[ede_diagnostic] x", "boom"]})) + else { + panic!() + }; + assert_eq!(message, "boom"); +} + +#[test] +fn failures_reported_as_assistant_messages() { + let synthetic = |text: &str, error: Option<&str>| json!({"type": "assistant", "error": error, "message": {"model": "", "content": [{"type": "text", "text": text}]}}); + let limit = failure_of(&synthetic("You've hit your limit · resets 5pm", Some("rate_limit")), Some(7.0)).unwrap(); + assert_eq!( + limit, + TurnEnd::Fail { + message: "You've hit your limit · resets 5pm".into(), + data: Some(json!({"openidea": {"usageLimit": {"resetsAtMs": 7.0}}})), + } + ); + let TurnEnd::Fail { message, data: None } = + failure_of(&synthetic("Invalid API key · Please run /login", Some("authentication_failed")), None).unwrap() + else { + panic!() + }; + assert!(message.contains("auth")); + let real = json!({"type": "assistant", "message": {"model": "claude-haiku-5-5", "content": [{"type": "text", "text": "You've hit your stride"}]}}); + assert_eq!(failure_of(&real, None), None); +} + +#[test] +fn permission_answers() { + let input = json!({"command": "ls"}); + let suggestions = json!([{"type": "addRules", "rules": [{"toolName": "Bash", "ruleContent": "ls"}], "behavior": "allow", "destination": "localSettings"}]); + let always = permission_result("Bash", &input, "t", Some(&suggestions), Some("allow-always")); + assert_eq!(always["behavior"], "allow"); + assert_eq!(always["updatedPermissions"][0]["destination"], "session"); + assert_eq!(always["updatedInput"], input); + let fallback = permission_result("Bash", &input, "t", None, Some("allow-always")); + assert_eq!(fallback["updatedPermissions"][0]["rules"][0]["toolName"], "Bash"); + assert_eq!(permission_result("Bash", &input, "t", None, Some("reject"))["behavior"], "deny"); + assert_eq!(permission_result("Bash", &input, "t", None, None)["behavior"], "deny"); + let plan = permission_result("ExitPlanMode", &json!({"plan": "p"}), "t", None, Some("plan-accept-edits")); + assert_eq!( + plan["updatedPermissions"][0], + json!({"type": "setMode", "mode": "acceptEdits", "destination": "session"}) + ); +} + +#[test] +fn pending_starts_round_trip() { + for s in [ + Start::New { id: "a".into() }, + Start::Resume { id: "a".into(), at: Some("u".into()) }, + Start::Fork { from: "a".into(), id: "b".into(), at: None }, + ] { + assert_eq!(start_from_json(&start_json(&s)), Some(s)); + } +} + +#[test] +fn streamed_text_is_not_sent_twice() { + let mut t = Translator::new("s", PathBuf::from("/w"), true); + let se = |event: Value| json!({"type": "stream_event", "parent_tool_use_id": null, "event": event}); + let mut out = Vec::new(); + out.extend(t.on_message(&se( + json!({"type": "message_start", "message": {"id": "m1", "model": "x", "usage": {"input_tokens": 10}}}), + ))); + out.extend(t.on_message(&se( + json!({"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}}), + ))); + out.extend(t.on_message(&se( + json!({"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "hm"}}), + ))); + out.extend(t.on_message(&se( + json!({"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}}), + ))); + out.extend(t.on_message(&se( + json!({"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "Hel"}}), + ))); + // The whole message: its thinking, then its text (one more piece than streamed). + let whole = |content: Value| { + json!({"type": "assistant", "parent_tool_use_id": null, + "message": {"id": "m1", "model": "x", "content": content, "usage": {"input_tokens": 10, "output_tokens": 5}}}) + }; + out.extend(t.on_message(&whole(json!([{"type": "thinking", "thinking": "hm"}])))); + out.extend(t.on_message(&whole(json!([{"type": "text", "text": "Hello"}])))); + let texts: Vec<(&str, &str)> = out + .iter() + .filter_map(|u| Some((u.update["sessionUpdate"].as_str()?, u.update["content"]["text"].as_str()?))) + .collect(); + assert_eq!(texts, [("agent_thought_chunk", "hm"), ("agent_message_chunk", "Hel"), ("agent_message_chunk", "lo")]); + // Usage needs the window, which a result reports. + let usage = t.on_result(&json!({"modelUsage": {"x": {"contextWindow": 1000}}})); + assert_eq!(usage[0].update, json!({"sessionUpdate": "usage_update", "used": 15, "size": 1000})); +} + +#[test] +fn tool_calls_plans_and_subagents() { + let mut t = Translator::new("s", PathBuf::from("/w"), true); + let assistant = |parent: Value, content: Value| json!({"type": "assistant", "parent_tool_use_id": parent, "message": {"id": "m", "model": "x", "content": content}}); + let user = |parent: Value, content: Value, structured: Value| json!({"type": "user", "parent_tool_use_id": parent, "message": {"role": "user", "content": content}, "tool_use_result": structured}); + let todo = t.on_message(&assistant( + Value::Null, + json!([{"type": "tool_use", "id": "td", "name": "TodoWrite", "input": {"todos": [{"content": "a", "status": "pending"}]}}]), + )); + assert_eq!(todo[0].update["sessionUpdate"], "plan"); + let launch = t.on_message(&assistant( + Value::Null, + json!([{"type": "tool_use", "id": "ag", "name": "Agent", "input": {"description": "Look", "subagent_type": "Explore", "prompt": "find it"}}]), + )); + let kinds: Vec<(&str, &str)> = + launch.iter().map(|u| (u.session.as_str(), u.update["sessionUpdate"].as_str().unwrap())).collect(); + assert_eq!(kinds, [("s", "subagent_update"), ("s", "session_message"), ("ag", "session_message")]); + t.on_message(&json!({"type": "system", "subtype": "task_started", "task_id": "a1", "tool_use_id": "ag", "task_type": "local_agent"})); + assert_eq!(t.session_of_agent(Some("a1")), "ag"); + assert_eq!(t.agent_of("ag").as_deref(), Some("a1")); + // The subagent's own tool call goes to its session. + let inner = t.on_message(&assistant( + json!("ag"), + json!([{"type": "tool_use", "id": "b1", "name": "Bash", "input": {"command": "ls"}}]), + )); + assert_eq!((inner[0].session.as_str(), inner[0].update["sessionUpdate"].as_str()), ("ag", Some("tool_call"))); + let done = t.on_message(&user( + json!("ag"), + json!([{"type": "tool_result", "tool_use_id": "b1", "content": "x"}]), + json!({"stdout": "x", "stderr": ""}), + )); + assert_eq!(done[0].update["status"], "completed"); + t.on_message(&assistant(json!("ag"), json!([{"type": "text", "text": "It is x"}]))); + // Launched in the background: it reports in a task notification. + let launched = t.on_message(&user( + Value::Null, + json!([{"type": "tool_result", "tool_use_id": "ag", "content": "Async agent launched"}]), + json!({"status": "async_launched"}), + )); + assert!(launched.is_empty()); + let finished = t.on_message( + &json!({"type": "system", "subtype": "task_notification", "tool_use_id": "ag", "status": "completed"}), + ); + assert_eq!(finished[0].update["state"], json!({"state": "idle", "stopReason": "end_turn"})); + assert_eq!(finished[1].update["content"][0]["text"], "It is x"); + assert_eq!(finished[1].update["senderSessionId"], "ag"); +} diff --git a/crates/ide-agent/src/native/claude/tools.rs b/crates/ide-agent/src/native/claude/tools.rs new file mode 100644 index 000000000..4341e2c19 --- /dev/null +++ b/crates/ide-agent/src/native/claude/tools.rs @@ -0,0 +1,259 @@ +//! How Claude Code's tools look as ACP tool calls: title, kind, locations and content. + +use serde_json::{Value, json}; +use std::path::Path; + +/// Tools reported some other way than as a tool call. +pub(super) fn is_agent(name: &str) -> bool { + matches!(name, "Agent" | "Task") +} + +/// Tools whose calls become the session's plan instead of tool calls. +pub(super) fn is_plan(name: &str) -> bool { + matches!(name, "TodoWrite" | "TaskCreate" | "TaskUpdate" | "TaskList" | "TaskGet") +} + +/// The fields of a `tool_call` for a use of tool `name` with `input`. +pub(super) fn call_fields(name: &str, input: &Value, cwd: &Path) -> Value { + let s = |k: &str| input.get(k).and_then(Value::as_str).filter(|v| !v.is_empty()); + let path = s("file_path").or_else(|| s("notebook_path")).or_else(|| s("path")); + let shown = path.map(|p| display_path(p, cwd)); + let (title, kind) = match name { + "Bash" | "PowerShell" => (s("command").map(first_line).unwrap_or_else(|| "Terminal".into()), "execute"), + "Read" => { + let range = match (input.get("offset").and_then(Value::as_u64), input.get("limit").and_then(Value::as_u64)) + { + (o, Some(l)) if l > 0 => { + let from = o.unwrap_or(1); + format!(" ({from} - {})", from + l - 1) + } + (Some(o), _) => format!(" (from line {o})"), + _ => String::new(), + }; + (format!("Read {}{range}", shown.as_deref().unwrap_or("file")), "read") + } + "Write" => (shown.map_or_else(|| "Write".into(), |p| format!("Write {p}")), "edit"), + "Edit" | "MultiEdit" => (shown.map_or_else(|| "Edit".into(), |p| format!("Edit {p}")), "edit"), + "NotebookEdit" => (shown.map_or_else(|| "Edit notebook".into(), |p| format!("Edit {p}")), "edit"), + "Glob" => (format!("Find {}", s("pattern").unwrap_or_default()).trim_end().to_owned(), "search"), + "Grep" => { + let mut t = format!("grep \"{}\"", s("pattern").unwrap_or_default()); + if let Some(p) = path { + t.push(' '); + t.push_str(&display_path(p, cwd)); + } + (t, "search") + } + "WebFetch" => (s("url").map_or_else(|| "Fetch".into(), |u| format!("Fetch {u}")), "fetch"), + "WebSearch" => (s("query").map_or_else(|| "Web search".into(), |q| format!("Search \"{q}\"")), "fetch"), + "Agent" | "Task" => (s("description").unwrap_or("Subagent").to_owned(), "think"), + "ExitPlanMode" => ("Ready to code?".into(), "switch_mode"), + "EnterPlanMode" => ("Enter plan mode".into(), "switch_mode"), + "Skill" => (s("skill").map_or_else(|| "Load skill".into(), |k| format!("Load skill: {k}")), "other"), + _ => (mcp_title(name), "other"), + }; + let mut out = json!({ "title": title, "kind": kind, "rawInput": input }); + if let Some(p) = path + && matches!(kind, "read" | "edit" | "search") + { + let mut loc = json!({ "path": p }); + if name == "Read" { + loc["line"] = json!(input.get("offset").and_then(Value::as_u64).unwrap_or(1)); + } + out["locations"] = json!([loc]); + } + let content = input_content(name, input); + if !content.is_empty() { + out["content"] = Value::Array(content); + } + out +} + +/// What the input itself shows: an edit's change, a command's purpose, a plan. +fn input_content(name: &str, input: &Value) -> Vec { + let s = |k: &str| input.get(k).and_then(Value::as_str); + match name { + "Edit" => match s("file_path") { + Some(p) => vec![diff(p, s("old_string"), s("new_string").unwrap_or_default())], + None => Vec::new(), + }, + "MultiEdit" => { + let Some(p) = s("file_path") else { return Vec::new() }; + let edits = input.get("edits").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + edits + .iter() + .map(|e| { + let field = |k: &str| e.get(k).and_then(Value::as_str); + diff(p, field("old_string"), field("new_string").unwrap_or_default()) + }) + .collect() + } + "Write" => match (s("file_path"), s("content")) { + (Some(p), Some(c)) => vec![diff(p, None, c)], + _ => Vec::new(), + }, + "ExitPlanMode" => s("plan").map(|p| vec![text(p)]).unwrap_or_default(), + "Bash" | "WebFetch" => s("description").or_else(|| s("prompt")).map(|d| vec![text(d)]).unwrap_or_default(), + _ => Vec::new(), + } +} + +/// The `tool_call_update` fields that finish a call: status and what it produced. +/// `structured` is the message's `tool_use_result`. +pub(super) fn result_fields(name: &str, input: &Value, block: &Value, structured: Option<&Value>) -> Value { + let failed = block.get("is_error").and_then(Value::as_bool) == Some(true); + let mut content = Vec::new(); + match name { + "Edit" | "MultiEdit" | "Write" if !failed => { + // The final change: a write that replaced a file shows what it replaced. + content = input_content(name, input); + if name == "Write" + && let (Some(old), Some(c)) = + (structured.and_then(|s| s.get("originalFile")).and_then(Value::as_str), content.first_mut()) + { + c["oldText"] = json!(old); + } + } + "Bash" | "PowerShell" => { + let out = structured + .filter(|s| s.get("stdout").is_some()) + .map(|s| { + let part = |k: &str| s.get(k).and_then(Value::as_str).unwrap_or_default(); + [part("stdout"), part("stderr")] + .iter() + .filter(|p| !p.is_empty()) + .copied() + .collect::>() + .join("\n") + }) + .unwrap_or_else(|| result_text(block)); + if !out.is_empty() { + content.push(text(&out)); + } + } + _ => { + let t = result_text(block); + if !t.is_empty() { + content.push(text(&t)); + } + } + } + if failed && content.is_empty() { + let t = result_text(block); + if !t.is_empty() { + content.push(text(&t)); + } + } + let mut out = json!({ "status": if failed { "failed" } else { "completed" } }); + if !content.is_empty() { + out["content"] = Value::Array(content); + } + out +} + +/// The text of a `tool_result` block's content (a string or text blocks). +pub(super) fn result_text(block: &Value) -> String { + match block.get("content") { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(items)) => { + items.iter().filter_map(|b| b.get("text").and_then(Value::as_str)).collect::>().join("\n") + } + _ => String::new(), + } +} + +/// `TodoWrite` input as ACP plan entries. +pub(super) fn todo_entries(input: &Value) -> Option> { + let todos = input.get("todos")?.as_array()?; + Some( + todos + .iter() + .map(|t| { + let s = |k: &str| t.get(k).and_then(Value::as_str).unwrap_or_default(); + let status = s("status"); + let content = + if status == "in_progress" && !s("activeForm").is_empty() { s("activeForm") } else { s("content") }; + json!({ "content": content, "status": status, "priority": "medium" }) + }) + .collect(), + ) +} + +pub(super) fn text(t: &str) -> Value { + json!({ "type": "content", "content": { "type": "text", "text": t } }) +} + +fn diff(path: &str, old: Option<&str>, new: &str) -> Value { + json!({ "type": "diff", "path": path, "oldText": old, "newText": new }) +} + +fn first_line(s: &str) -> String { + let line = s.lines().next().unwrap_or_default(); + if s.contains('\n') { format!("{line} …") } else { line.to_owned() } +} + +/// `mcp__server__tool` as `server: tool`. +fn mcp_title(name: &str) -> String { + match name.strip_prefix("mcp__").and_then(|r| r.split_once("__")) { + Some((server, tool)) => format!("{server}: {tool}"), + None => name.to_owned(), + } +} + +fn display_path(path: &str, cwd: &Path) -> String { + Path::new(path) + .strip_prefix(cwd) + .ok() + .filter(|r| !r.as_os_str().is_empty()) + .map_or_else(|| path.to_owned(), |r| r.to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn titles_kinds_and_changes() { + let cwd = Path::new("/w"); + let edit = call_fields("Edit", &json!({"file_path": "/w/src/a.rs", "old_string": "a", "new_string": "b"}), cwd); + assert_eq!(edit["title"], "Edit src/a.rs"); + assert_eq!(edit["kind"], "edit"); + assert_eq!(edit["locations"], json!([{"path": "/w/src/a.rs"}])); + assert_eq!(edit["content"], json!([{"type": "diff", "path": "/w/src/a.rs", "oldText": "a", "newText": "b"}])); + let read = call_fields("Read", &json!({"file_path": "/x/b.rs", "offset": 10, "limit": 5}), cwd); + assert_eq!((read["title"].as_str(), read["kind"].as_str()), (Some("Read /x/b.rs (10 - 14)"), Some("read"))); + assert_eq!(read["locations"], json!([{"path": "/x/b.rs", "line": 10}])); + let bash = call_fields("Bash", &json!({"command": "ls\npwd", "description": "List"}), cwd); + assert_eq!((bash["title"].as_str(), bash["kind"].as_str()), (Some("ls …"), Some("execute"))); + assert_eq!(call_fields("Grep", &json!({"pattern": "fn", "path": "/w/src"}), cwd)["title"], "grep \"fn\" src"); + assert_eq!(call_fields("mcp__docs__read", &json!({}), cwd)["title"], "docs: read"); + assert_eq!(call_fields("WebSearch", &json!({"query": "q"}), cwd)["kind"], "fetch"); + } + + #[test] + fn results() { + let input = json!({"file_path": "/w/n.txt", "content": "two"}); + let r = result_fields( + "Write", + &input, + &json!({"type": "tool_result", "content": "File created"}), + Some(&json!({"type": "update", "originalFile": "one"})), + ); + assert_eq!( + r, + json!({"status": "completed", "content": [{"type": "diff", "path": "/w/n.txt", "oldText": "one", "newText": "two"}]}) + ); + let denied = result_fields("Edit", &json!({}), &json!({"content": "User denied", "is_error": true}), None); + assert_eq!(denied, json!({"status": "failed", "content": [text("User denied")]})); + let bash = result_fields( + "Bash", + &json!({}), + &json!({"content": "x"}), + Some(&json!({"stdout": "out", "stderr": "err"})), + ); + assert_eq!(bash["content"], json!([text("out\nerr")])); + let todos = + todo_entries(&json!({"todos": [{"content": "A", "activeForm": "Doing A", "status": "in_progress"}]})); + assert_eq!(todos.unwrap()[0]["content"], "Doing A"); + } +} diff --git a/crates/ide-agent/src/native/claude/translate.rs b/crates/ide-agent/src/native/claude/translate.rs new file mode 100644 index 000000000..534e0eee6 --- /dev/null +++ b/crates/ide-agent/src/native/claude/translate.rs @@ -0,0 +1,563 @@ +//! Claude Code's conversation messages (`assistant`, `user`, `stream_event`, task events) +//! as ACP `session/update`s, for live sessions and for replayed history alike. +//! +//! Subagents (the Agent tool) become ACP subagent sessions whose id is the id of the +//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`. + +use super::tools; +use serde_json::{Value, json}; +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; + +/// One `session/update`: the session it belongs to and the update object. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Update { + pub session: String, + pub update: Value, +} + +struct ToolUse { + name: String, + input: Value, + /// The session the call belongs to. + session: String, + /// A `tool_call` went out for it. + emitted: bool, +} + +struct Child { + /// The session that launched it. + parent: String, + /// What it said last: its answer when it finishes in the background. + last_text: String, + /// Runs in the background (`task_started`): it reports in a task notification, else + /// in its tool result. + background: Option, + done: bool, +} + +pub(super) struct Translator { + root: String, + cwd: PathBuf, + /// The client shows subagents as sessions of their own. + subagents: bool, + /// Text and thinking blocks that streamed, by API message id: (thought, text). + streamed: HashMap>, + /// The message streaming now, per session. + streaming: HashMap, + tools: HashMap, + /// Subagents by the id of their Agent tool use (their session id). + children: HashMap, + /// Agent ids (`agent_id`, `task_id`) to the subagent's session id. + agent_ids: HashMap, + /// `TaskCreate` lists: (task id, content, status). + tasks: Vec<(String, String, String)>, + /// Context use of the main conversation: tokens in its last request, the model, the + /// window of each model seen in a `result`. + pub used: Option, + pub model: Option, + windows: HashMap, +} + +impl Translator { + pub fn new(root: &str, cwd: PathBuf, subagents: bool) -> Self { + Self { + root: root.to_owned(), + cwd, + subagents, + streamed: HashMap::new(), + streaming: HashMap::new(), + tools: HashMap::new(), + children: HashMap::new(), + agent_ids: HashMap::new(), + tasks: Vec::new(), + used: None, + model: None, + windows: HashMap::new(), + } + } + + /// The session a message with `parent_tool_use_id` belongs to; `None` when it is a + /// subagent's the client doesn't show. + fn owner(&self, parent: Option<&str>) -> Option { + match parent { + None => Some(self.root.clone()), + Some(p) if self.children.contains_key(p) => Some(p.to_owned()), + Some(_) => None, + } + } + + /// The session of subagent `agent_id` (a permission request's), else the main one. + pub fn session_of_agent(&self, agent_id: Option<&str>) -> String { + agent_id.and_then(|a| self.agent_ids.get(a)).cloned().unwrap_or_else(|| self.root.clone()) + } + + /// The subagent session `session`'s agent id, to stop it. + pub fn agent_of(&self, session: &str) -> Option { + self.agent_ids.iter().find(|(_, s)| *s == session).map(|(a, _)| a.clone()) + } + + /// The call `tool_use_id` as a `toolCall` for a permission request. + pub fn tool_call(&self, tool_use_id: &str, name: &str, input: &Value) -> Value { + let mut call = tools::call_fields(name, input, &self.cwd); + call["toolCallId"] = json!(tool_use_id); + call + } + + /// Whether a `tool_call` went out for `id`. + pub fn emitted(&self, id: &str) -> bool { + self.tools.get(id).is_some_and(|t| t.emitted) + } + + /// Ensures the call `id` is shown before something refers to it (a permission request). + pub fn ensure_call(&mut self, id: &str, name: &str, input: &Value, session: &str) -> Vec { + if self.emitted(id) || tools::is_agent(name) && self.subagents { + return Vec::new(); + } + let mut call = self.tool_call(id, name, input); + call["sessionUpdate"] = json!("tool_call"); + call["status"] = json!("pending"); + self.tools.insert( + id.to_owned(), + ToolUse { name: name.to_owned(), input: input.clone(), session: session.to_owned(), emitted: true }, + ); + vec![Update { session: session.to_owned(), update: call }] + } + + pub fn on_message(&mut self, msg: &Value) -> Vec { + match msg.get("type").and_then(Value::as_str) { + Some("stream_event") => self.on_stream(msg), + Some("assistant") => self.on_assistant(msg), + Some("user") => self.on_user(msg, false), + Some("system") => self.on_system(msg), + _ => Vec::new(), + } + } + + /// A user message of replayed history: the prompts too. + pub fn on_history_user(&mut self, msg: &Value) -> Vec { + self.on_user(msg, true) + } + + fn on_stream(&mut self, msg: &Value) -> Vec { + let parent = msg.get("parent_tool_use_id").and_then(Value::as_str); + let Some(session) = self.owner(parent) else { return Vec::new() }; + let Some(event) = msg.get("event") else { return Vec::new() }; + let mut out = Vec::new(); + match event.get("type").and_then(Value::as_str) { + Some("message_start") => { + if let Some(id) = event.pointer("/message/id").and_then(Value::as_str) { + self.streaming.insert(session.clone(), id.to_owned()); + } + if parent.is_none() { + self.note_usage(event.pointer("/message/usage"), event.pointer("/message/model")); + } + } + Some("message_delta") if parent.is_none() => { + self.note_usage(event.get("usage"), None); + out.extend(self.usage_update()); + } + Some("content_block_start") => { + let block = event.get("content_block").cloned().unwrap_or_default(); + match block.get("type").and_then(Value::as_str) { + Some(kind @ ("text" | "thinking")) => { + if let Some(id) = self.streaming.get(&session) { + self.streamed.entry(id.clone()).or_default().push_back((kind == "thinking", String::new())); + } + } + Some("tool_use") => { + // The input streams after this: shown by name until the message has it. + let (id, name) = (str_of(&block, "id"), str_of(&block, "name")); + if !id.is_empty() && !tools::is_plan(&name) && !(tools::is_agent(&name) && self.subagents) { + out.extend(self.ensure_call(&id, &name, &json!({}), &session)); + } + } + _ => {} + } + } + Some("content_block_delta") => { + let delta = event.get("delta").cloned().unwrap_or_default(); + let (thought, piece) = match delta.get("type").and_then(Value::as_str) { + Some("text_delta") => (false, str_of(&delta, "text")), + Some("thinking_delta") => (true, str_of(&delta, "thinking")), + _ => return out, + }; + if piece.is_empty() { + return out; + } + if let Some(id) = self.streaming.get(&session) + && let Some(last) = self.streamed.get_mut(id).and_then(|q| q.back_mut()) + && last.0 == thought + { + last.1.push_str(&piece); + } + out.push(chunk(&session, thought, &piece)); + } + _ => {} + } + out + } + + fn on_assistant(&mut self, msg: &Value) -> Vec { + let parent = msg.get("parent_tool_use_id").and_then(Value::as_str); + let Some(session) = self.owner(parent) else { return Vec::new() }; + let message = msg.get("message").cloned().unwrap_or_default(); + let msg_id = str_of(&message, "id"); + if parent.is_none() && message.get("model").and_then(Value::as_str) != Some("") { + self.note_usage(message.get("usage"), message.get("model")); + } + let mut out = Vec::new(); + for block in message.get("content").and_then(Value::as_array).into_iter().flatten() { + match block.get("type").and_then(Value::as_str) { + Some(kind @ ("text" | "thinking")) => { + let thought = kind == "thinking"; + let full = str_of(block, if thought { "thinking" } else { "text" }); + if !thought && let Some(child) = self.children.get_mut(&session) { + child.last_text = full.clone(); + } + // What streamed already is not sent again. + let streamed = self.streamed.get_mut(&msg_id).and_then(|q| { + let i = q.iter().position(|(t, _)| *t == thought)?; + q.remove(i) + }); + let rest = match &streamed { + Some((_, s)) => full.strip_prefix(s.as_str()).unwrap_or_default(), + None => full.as_str(), + }; + if !rest.is_empty() { + out.push(chunk(&session, thought, rest)); + } + } + Some("tool_use" | "server_tool_use" | "mcp_tool_use") => out.extend(self.on_tool_use(block, &session)), + _ => {} + } + } + if parent.is_none() { + out.extend(self.usage_update()); + } + out + } + + fn on_tool_use(&mut self, block: &Value, session: &str) -> Vec { + let (id, name) = (str_of(block, "id"), str_of(block, "name")); + let input = block.get("input").cloned().unwrap_or_else(|| json!({})); + if name == "TodoWrite" { + self.tools.insert(id, ToolUse { name, input: input.clone(), session: session.to_owned(), emitted: false }); + return tools::todo_entries(&input) + .map(|entries| vec![Update { session: session.to_owned(), update: plan(entries) }]) + .unwrap_or_default(); + } + if tools::is_agent(&name) && self.subagents { + let fresh = !self.children.contains_key(&id); + self.tools.insert( + id.clone(), + ToolUse { name, input: input.clone(), session: session.to_owned(), emitted: false }, + ); + if !fresh { + return Vec::new(); + } + self.children.insert( + id.clone(), + Child { parent: session.to_owned(), last_text: String::new(), background: None, done: false }, + ); + let s = |k: &str| input.get(k).and_then(Value::as_str); + let mut announce = json!({ + "sessionUpdate": "subagent_update", "sessionId": id, + "title": s("subagent_type").unwrap_or("Agent"), + "capabilities": { "cancel": {} }, "state": { "state": "running" }, + }); + if let Some(d) = s("description") { + announce["description"] = json!(d); + } + let mut out = vec![Update { session: session.to_owned(), update: announce }]; + if let Some(prompt) = s("prompt") { + let message = json!({ + "sessionUpdate": "session_message", "messageId": id, + "senderSessionId": session, "recipientSessionId": id, + "content": [{ "type": "text", "text": prompt }], + }); + out.push(Update { session: session.to_owned(), update: message.clone() }); + out.push(Update { session: id, update: message }); + } + return out; + } + if tools::is_plan(&name) { + self.tools.insert(id, ToolUse { name, input, session: session.to_owned(), emitted: false }); + return Vec::new(); + } + let was_emitted = self.emitted(&id); + let mut fields = self.tool_call(&id, &name, &input); + self.tools.insert(id, ToolUse { name, input, session: session.to_owned(), emitted: true }); + fields["sessionUpdate"] = json!(if was_emitted { "tool_call_update" } else { "tool_call" }); + if !was_emitted { + fields["status"] = json!("pending"); + } + vec![Update { session: session.to_owned(), update: fields }] + } + + fn on_user(&mut self, msg: &Value, history: bool) -> Vec { + let parent = msg.get("parent_tool_use_id").and_then(Value::as_str); + let Some(session) = self.owner(parent) else { return Vec::new() }; + let structured = msg.get("tool_use_result").or_else(|| msg.get("toolUseResult")); + let content = msg.pointer("/message/content"); + let mut out = Vec::new(); + if let Some(text) = content.and_then(Value::as_str) { + if history && parent.is_none() { + out.push(Update { + session, + update: json!({ "sessionUpdate": "user_message_chunk", "content": { "type": "text", "text": text } }), + }); + } + return out; + } + let blocks = content.and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let results = blocks.iter().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_result")).count(); + for block in blocks { + match block.get("type").and_then(Value::as_str) { + Some("tool_result") => { + // `tool_use_result` describes the message's only result. + let structured = structured.filter(|_| results == 1); + out.extend(self.on_tool_result(block, structured)); + } + Some("text") if history && parent.is_none() => out.push(Update { + session: session.clone(), + update: json!({ "sessionUpdate": "user_message_chunk", "content": { "type": "text", "text": str_of(block, "text") } }), + }), + Some("image") if history && parent.is_none() => { + let source = block.get("source").cloned().unwrap_or_default(); + out.push(Update { + session: session.clone(), + update: json!({ "sessionUpdate": "user_message_chunk", "content": { + "type": "image", "data": str_of(&source, "data"), "mimeType": str_of(&source, "media_type") } }), + }); + } + _ => {} + } + } + out + } + + fn on_tool_result(&mut self, block: &Value, structured: Option<&Value>) -> Vec { + let id = str_of(block, "tool_use_id"); + let Some(tool) = self.tools.get(&id) else { return Vec::new() }; + let (name, input, session, emitted) = + (tool.name.clone(), tool.input.clone(), tool.session.clone(), tool.emitted); + let failed = block.get("is_error").and_then(Value::as_bool) == Some(true); + if tools::is_agent(&name) && self.children.contains_key(&id) { + // A background launch reports its result later, in a task notification. + let launched = structured.and_then(|s| s.get("status")).and_then(Value::as_str) == Some("async_launched") + || structured.and_then(|s| s.get("isAsync")).and_then(Value::as_bool) == Some(true); + if launched && !failed { + return Vec::new(); + } + // Its report, without what the harness adds for the model. + let report = structured + .filter(|s| s.get("status").and_then(Value::as_str) == Some("completed")) + .and_then(|s| s.get("content")?.as_array().cloned()) + .map(|blocks| { + blocks + .iter() + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .filter(|t| !t.trim_start().starts_with(">() + .join("\n") + }) + .unwrap_or_else(|| tools::result_text(block)); + let state = if failed { "error" } else { "end_turn" }; + return self.finish_child(&id, state, Some(report)); + } + if tools::is_plan(&name) { + if !failed && let Some(entries) = self.task_plan(&name, &input, block, structured) { + return vec![Update { session, update: plan(entries) }]; + } + return Vec::new(); + } + if !emitted { + return Vec::new(); + } + let mut update = tools::result_fields(&name, &input, block, structured); + update["sessionUpdate"] = json!("tool_call_update"); + update["toolCallId"] = json!(id); + vec![Update { session, update }] + } + + /// Ends subagent `id` (`stop` an ACP stop reason, or `error`) with its answer. + fn finish_child(&mut self, id: &str, stop: &str, reply: Option) -> Vec { + let Some(child) = self.children.get_mut(id) else { return Vec::new() }; + if child.done { + return Vec::new(); + } + child.done = true; + let parent = child.parent.clone(); + let reply = reply.filter(|r| !r.trim().is_empty()).unwrap_or_else(|| child.last_text.clone()); + let mut state = json!({ "state": "idle", "stopReason": stop }); + if stop == "error" { + state["error"] = json!({ "code": -32603, "message": if reply.is_empty() { "The subagent failed." } else { reply.as_str() } }); + } + let mut out = vec![Update { + session: parent.clone(), + update: json!({ "sessionUpdate": "subagent_update", "sessionId": id, "state": state }), + }]; + if !reply.is_empty() && stop != "error" { + out.push(Update { + session: parent.clone(), + update: json!({ + "sessionUpdate": "session_message", "messageId": format!("{id}:reply"), + "senderSessionId": id, "recipientSessionId": parent, + "content": [{ "type": "text", "text": reply }], + }), + }); + } + out + } + + fn on_system(&mut self, msg: &Value) -> Vec { + let tool_use = str_of(msg, "tool_use_id"); + match msg.get("subtype").and_then(Value::as_str) { + Some("task_started") => { + if let Some(task) = msg.get("task_id").and_then(Value::as_str) + && let Some(child) = self.children.get_mut(&tool_use) + { + child.background = msg.get("is_backgrounded").and_then(Value::as_bool); + self.agent_ids.insert(task.to_owned(), tool_use); + } else if let Some(t) = self.tools.get(&tool_use).filter(|t| t.emitted) { + let update = + json!({ "sessionUpdate": "tool_call_update", "toolCallId": tool_use, "status": "in_progress" }); + return vec![Update { session: t.session.clone(), update }]; + } + Vec::new() + } + Some("task_notification") if self.children.get(&tool_use).is_some_and(|c| c.background != Some(false)) => { + let stop = match msg.get("status").and_then(Value::as_str) { + Some("failed") => "error", + Some("killed" | "stopped" | "cancelled") => "cancelled", + _ => "end_turn", + }; + let summary = msg.get("summary").and_then(Value::as_str).map(str::to_owned); + let last = self.children.get(&tool_use).map(|c| c.last_text.clone()).filter(|t| !t.is_empty()); + self.finish_child(&tool_use, stop, last.or(summary)) + } + _ => Vec::new(), + } + } + + /// Marks subagent `session` waiting for (or no longer waiting for) the user. + pub fn child_state(&self, session: &str, waiting: bool) -> Option { + let child = self.children.get(session).filter(|c| !c.done)?; + let state = if waiting { "requires_action" } else { "running" }; + Some(Update { + session: child.parent.clone(), + update: json!({ "sessionUpdate": "subagent_update", "sessionId": session, "state": { "state": state } }), + }) + } + + /// Subagents still working when their agent is gone. + pub fn stop_children(&mut self) -> Vec { + let ids: Vec = self.children.iter().filter(|(_, c)| !c.done).map(|(id, _)| id.clone()).collect(); + ids.iter().flat_map(|id| self.finish_child(id, "cancelled", None)).collect() + } + + fn task_plan( + &mut self, + name: &str, + input: &Value, + block: &Value, + structured: Option<&Value>, + ) -> Option> { + let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned); + match name { + "TaskCreate" => { + let id = + structured.and_then(|r| r.pointer("/task/id")).and_then(Value::as_str).map(str::to_owned).or_else( + || { + let text = tools::result_text(block); + let rest = text.trim().strip_prefix("Task #")?; + Some(rest.split_whitespace().next()?.to_owned()) + }, + )?; + self.tasks.push((id, s(input, "subject").unwrap_or_default(), "pending".into())); + } + "TaskUpdate" => { + let id = s(input, "taskId")?; + if s(input, "status").as_deref() == Some("deleted") { + self.tasks.retain(|t| t.0 != id); + } else { + let t = self.tasks.iter_mut().find(|t| t.0 == id)?; + if let Some(st) = s(input, "status") { + t.2 = st; + } + if let Some(sub) = s(input, "subject") { + t.1 = sub; + } + } + } + "TaskList" => { + let list = structured?.get("tasks")?.as_array()?; + self.tasks = list + .iter() + .filter_map(|t| { + Some((s(t, "id")?, s(t, "subject")?, s(t, "status").unwrap_or_else(|| "pending".into()))) + }) + .collect(); + } + _ => return None, + } + Some(self.tasks.iter().map(|(_, c, st)| json!({ "content": c, "status": st, "priority": "medium" })).collect()) + } + + fn note_usage(&mut self, usage: Option<&Value>, model: Option<&Value>) { + if let Some(m) = model.and_then(Value::as_str).filter(|m| *m != "") { + self.model = Some(m.to_owned()); + } + let Some(u) = usage else { return }; + let n = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0); + let total = + n("input_tokens") + n("output_tokens") + n("cache_read_input_tokens") + n("cache_creation_input_tokens"); + if total > 0 { + self.used = Some(total); + } + } + + /// Learns the context window of the models a `result` reports; returns the usage then. + pub fn on_result(&mut self, result: &Value) -> Vec { + for (model, u) in result.get("modelUsage").and_then(Value::as_object).into_iter().flatten() { + if let Some(w) = u.get("contextWindow").and_then(Value::as_u64).filter(|w| *w > 0) { + self.windows.insert(model.clone(), w); + } + } + self.usage_update().into_iter().collect() + } + + fn usage_update(&self) -> Option { + let used = self.used?; + let size = self + .model + .as_ref() + .and_then(|m| { + self.windows + .get(m) + .or_else(|| self.windows.iter().find(|(k, _)| k.starts_with(m.as_str())).map(|(_, v)| v)) + }) + .or_else(|| (self.windows.len() == 1).then(|| self.windows.values().next()).flatten())?; + Some(Update { + session: self.root.clone(), + update: json!({ "sessionUpdate": "usage_update", "used": used, "size": size }), + }) + } +} + +fn chunk(session: &str, thought: bool, text: &str) -> Update { + let kind = if thought { "agent_thought_chunk" } else { "agent_message_chunk" }; + Update { + session: session.to_owned(), + update: json!({ "sessionUpdate": kind, "content": { "type": "text", "text": text } }), + } +} + +fn plan(entries: Vec) -> Value { + json!({ "sessionUpdate": "plan", "entries": entries }) +} + +fn str_of(v: &Value, key: &str) -> String { + v.get(key).and_then(Value::as_str).unwrap_or_default().to_owned() +} diff --git a/crates/ide-agent/src/native/codex/mod.rs b/crates/ide-agent/src/native/codex/mod.rs new file mode 100644 index 000000000..a83fd4597 --- /dev/null +++ b/crates/ide-agent/src/native/codex/mod.rs @@ -0,0 +1,951 @@ +//! Codex over `codex app-server`: JSON-RPC (v2 threads and turns) on the child's stdio, +//! translated to and from ACP. A codex thread is an ACP session; a subagent's thread is +//! a subagent session of the thread that spawned it. + +mod translate; + +#[cfg(test)] +mod tests; + +use super::Shim; +use crate::provider::ProviderSpec; +use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes}; +use parking_lot::Mutex; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::{Arc, OnceLock, Weak}; +use std::time::{Duration, Instant}; +use tokio::sync::oneshot; +use translate::{Choice, Failure, ModelInfo, Settings}; + +/// Notifications codex need not send: the client builds diffs from the items. +const OPT_OUT: [&str; 1] = ["turn/diff/updated"]; +/// A running command's output is sent at most this often. +const OUTPUT_EVERY: Duration = Duration::from_millis(200); +/// How long a `session/cancel` with no turn running still stops the prompt that follows. +const EARLY_CANCEL: Duration = Duration::from_secs(1); +/// How long the client's connection outlives codex. +const EXIT_GRACE: Duration = Duration::from_millis(100); +/// ACP's error code for a request that needs the user to sign in. +const AUTH_REQUIRED: i64 = -32000; + +pub(crate) fn connect( + spec: &ProviderSpec, + cwd: &Path, + client: Arc, + on_stderr: Box, +) -> std::io::Result { + attach(client, |events| { + let command = super::newest_install(&spec.command); + Peer::spawn_framed(&command, &spec.args, cwd, &spec.env, events, on_stderr, Framing::Lines) + }) +} + +/// Connects `client` to a shim driving the codex connection `connect_codex` opens (it +/// gets the handler for what codex sends). Each side's end closes the other. +fn attach( + client: Arc, + connect_codex: impl FnOnce(Arc) -> std::io::Result, +) -> std::io::Result { + let shim = Arc::new(CodexShim { + runtime: tokio::runtime::Handle::current(), + codex: OnceLock::new(), + client: OnceLock::new(), + state: Mutex::new(State::default()), + models: tokio::sync::Mutex::new(None), + }); + let codex = connect_codex(Arc::new(CodexEvents(Arc::downgrade(&shim))))?; + let _ = shim.codex.set(codex.clone()); + let ours = super::serve(shim.clone(), client); + let to_client = shim.client.get().cloned().expect("serve attaches the client"); + { + let (shim, codex, to_client) = (shim.clone(), codex.clone(), to_client.clone()); + tokio::spawn(async move { + codex.closed().await; + shim.codex_gone(); + // Answers already on their way (the turn that just ended) go out first. + tokio::time::sleep(EXIT_GRACE).await; + to_client.kill(); + }); + } + tokio::spawn(async move { + to_client.closed().await; + codex.kill(); + }); + Ok(ours) +} + +struct CodexShim { + runtime: tokio::runtime::Handle, + codex: OnceLock, + client: OnceLock, + state: Mutex, + /// `model/list`, fetched once. + models: tokio::sync::Mutex>>>, +} + +#[derive(Default)] +struct State { + initialized: bool, + /// By codex thread id, which is also the ACP session id. + threads: HashMap, + /// The last `account/rateLimits/updated` snapshot. + rate_limits: Option, + /// Codex exited. + gone: bool, +} + +#[derive(Default)] +struct Thread { + /// The session whose agent spawned this one (a subagent's thread). + parent: Option, + /// What turns run with (sessions of their own only). + settings: Option, + cwd: Option, + /// The turn running now. + turn: Option, + /// The last turn that completed. + completed: Option, + /// The `session/prompt` waiting for its turn to end. + prompt: Option, + /// The current turn's tool items, for approval titles. + items: HashMap, + /// Output of running commands, by item id. + outputs: HashMap, + /// Items whose text was streamed (their completion doesn't repeat it). + streamed: HashSet, + /// Kind and item of the text chunk sent last, while nothing else followed it. + last_chunk: Option<(&'static str, String)>, + /// The last error reported for the running turn. + error: Option, + /// A `session/cancel` that came with no turn running: it can overtake the + /// `session/prompt` sent just before it. + early_cancel: Option, + /// The latest agent message (a subagent's answer). + answer: String, +} + +struct PendingPrompt { + done: oneshot::Sender<(Value, Option)>, + /// Known once `turn/start` answers or `turn/started` arrives. + turn: Option, + /// Cancelled before the turn was known: interrupt it as soon as it is. + cancel: bool, +} + +struct Output { + text: String, + sent: Instant, +} + +/// Receives what codex sends. +struct CodexEvents(Weak); + +impl Handler for CodexEvents { + fn on_notification(&self, method: &str, params: Value) { + if let Some(shim) = self.0.upgrade() { + shim.on_codex_notification(method, ¶ms); + } + } + + fn on_request(&self, method: &str, params: Value) -> Result { + let shim = self.0.upgrade().ok_or((codes::INTERNAL_ERROR, "closed".to_owned()))?; + shim.on_codex_request(method, ¶ms) + } +} + +impl Shim for CodexShim { + fn attach(&self, client: Peer) { + let _ = self.client.set(client); + } +} + +impl Handler for CodexShim { + fn on_notification(&self, method: &str, params: Value) { + if method == "session/cancel" + && let Some(session) = params.get("sessionId").and_then(Value::as_str) + { + self.cancel(session); + } + } + + fn on_request(&self, method: &str, params: Value) -> Result { + self.on_request_with_data(method, params).map_err(|(code, message, _)| (code, message)) + } + + fn on_request_with_data(&self, method: &str, params: Value) -> Result)> { + self.runtime.block_on(self.handle(method, params)).map_err(|e| (e.code, e.message, e.data)) + } +} + +/// The `error` of an ACP response. +#[derive(Debug)] +struct ResponseError { + code: i64, + message: String, + data: Option, +} + +fn error(code: i64, message: impl Into) -> ResponseError { + ResponseError { code, message: message.into(), data: None } +} + +fn invalid(message: impl Into) -> ResponseError { + error(codes::INVALID_PARAMS, message) +} + +/// A failed codex request, for the ACP client. +fn from_codex(e: RpcError) -> ResponseError { + match e { + RpcError::Server { code, message, data } => { + let code = if message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code }; + ResponseError { code, message, data } + } + RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"), + e => error(codes::INTERNAL_ERROR, e.to_string()), + } +} + +fn str_param<'a>(params: &'a Value, key: &str) -> Result<&'a str, ResponseError> { + params.get(key).and_then(Value::as_str).ok_or_else(|| invalid(format!("missing {key}"))) +} + +impl CodexShim { + fn codex(&self) -> &Peer { + self.codex.get().expect("attached before any message") + } + + async fn call(&self, method: &str, params: Value) -> Result { + self.codex().request(method, params, None).await.map_err(from_codex) + } + + async fn handle(&self, method: &str, params: Value) -> Result { + match method { + "initialize" => self.initialize().await, + "session/new" => self.new_session(¶ms).await, + "session/load" => self.load_session(¶ms).await, + "session/fork" => self.fork_session(¶ms).await, + "session/set_config_option" => self.set_config(¶ms).await, + "session/prompt" => self.prompt(¶ms).await, + "_openidea/steer" => self.steer(¶ms).await, + "_openidea/rollback" => self.rollback(¶ms).await, + "session/close" => self.close(¶ms).await, + _ => Err(error(codes::METHOD_NOT_FOUND, format!("unsupported method {method}"))), + } + } + + async fn initialize(&self) -> Result { + let info = self + .call( + "initialize", + json!({ + "clientInfo": { "name": "openidea", "title": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") }, + "capabilities": { "experimentalApi": true, "optOutNotificationMethods": OPT_OUT }, + }), + ) + .await?; + self.codex().notify("initialized", Value::Null).map_err(from_codex)?; + self.state.lock().initialized = true; + // `/ () …` + let version = info + .get("userAgent") + .and_then(Value::as_str) + .and_then(|ua| ua.split_whitespace().next()) + .and_then(|first| first.split_once('/')) + .map(|(_, v)| v.to_owned()); + Ok(json!({ + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "promptCapabilities": { "image": true, "audio": false, "embeddedContext": true }, + "mcpCapabilities": { "http": false, "sse": false }, + "sessionCapabilities": { "fork": {}, "close": {} }, + "_meta": { "openidea": { "rollback": true, "steer": true } }, + }, + "agentInfo": { "name": "codex", "title": "Codex", "version": version }, + "authMethods": [], + })) + } + + /// The models codex offers; empty when it can't list them. + async fn models(&self) -> Arc> { + let mut cached = self.models.lock().await; + if let Some(m) = cached.as_ref() { + return m.clone(); + } + let mut all = Vec::new(); + let mut cursor = Value::Null; + loop { + match self.call("model/list", json!({ "cursor": cursor })).await { + Ok(page) => { + all.extend(translate::models_of(&page)); + match page.get("nextCursor").filter(|c| c.is_string()) { + Some(next) if *next != cursor => cursor = next.clone(), + _ => break, + } + } + Err(e) => { + tracing::warn!("codex: listing models failed: {}", e.message); + return Arc::new(all); + } + } + } + let models = Arc::new(all); + *cached = Some(models.clone()); + models + } + + /// Registers a thread codex opened for a session of its own; returns its `configOptions`. + fn open_thread(&self, result: &Value, models: &[ModelInfo], settings: Option) -> (String, Value) { + let thread = result.get("thread").unwrap_or(&Value::Null); + let id = thread.get("id").and_then(Value::as_str).unwrap_or_default().to_owned(); + let settings = settings.unwrap_or_else(|| Settings::of(result, models)); + let options = translate::config_options(models, &settings); + let cwd = result.get("cwd").or(thread.get("cwd")).and_then(Value::as_str).map(str::to_owned); + let mut state = self.state.lock(); + let t = state.threads.entry(id.clone()).or_default(); + t.parent = None; + t.settings = Some(settings); + t.cwd = cwd; + (id, options) + } + + async fn new_session(&self, params: &Value) -> Result { + let models = self.models().await; + let result = self.call("thread/start", json!({ "cwd": params.get("cwd") })).await?; + let (id, options) = self.open_thread(&result, &models, None); + if id.is_empty() { + return Err(error(codes::INTERNAL_ERROR, "codex started no thread")); + } + Ok(json!({ "sessionId": id, "configOptions": options })) + } + + /// Resumes the thread and replays its history before answering. + async fn load_session(&self, params: &Value) -> Result { + let id = str_param(params, "sessionId")?; + let models = self.models().await; + let result = self.call("thread/resume", json!({ "threadId": id, "cwd": params.get("cwd") })).await?; + let (_, options) = self.open_thread(&result, &models, None); + let cwd = self.state.lock().threads.get(id).and_then(|t| t.cwd.clone()); + let thread = result.get("thread").unwrap_or(&Value::Null); + for update in translate::history(thread, cwd.as_deref()) { + self.emit(id, update); + } + Ok(json!({ "configOptions": options })) + } + + async fn fork_session(&self, params: &Value) -> Result { + let source = str_param(params, "sessionId")?; + let models = self.models().await; + let settings = self.state.lock().threads.get(source).and_then(|t| t.settings.clone()); + let mut request = json!({ "threadId": source, "cwd": params.get("cwd") }); + if let Some(s) = &settings { + request["model"] = json!(s.model_slug(&models)); + request["approvalPolicy"] = json!(s.mode.approval_policy()); + request["sandbox"] = json!(s.mode.sandbox_mode()); + } + let result = self.call("thread/fork", request).await?; + let (id, options) = self.open_thread(&result, &models, settings); + Ok(json!({ "sessionId": id, "configOptions": options })) + } + + async fn set_config(&self, params: &Value) -> Result { + let session = str_param(params, "sessionId")?; + let option = str_param(params, "configId")?; + let value = str_param(params, "value")?; + let models = self.models().await; + let mut state = self.state.lock(); + let settings = state + .threads + .get_mut(session) + .and_then(|t| t.settings.as_mut()) + .ok_or_else(|| invalid(format!("no session {session}")))?; + settings.set(&models, option, value).map_err(invalid)?; + Ok(json!({ "configOptions": translate::config_options(&models, settings) })) + } + + async fn prompt(&self, params: &Value) -> Result { + let session = str_param(params, "sessionId")?.to_owned(); + let input = translate::user_input(params.get("prompt").unwrap_or(&Value::Null)); + if input.is_empty() { + return Err(invalid("empty prompt")); + } + let models = self.models().await; + let (done, ended) = oneshot::channel(); + let settings = { + let mut state = self.state.lock(); + if state.gone { + return Err(error(codes::INTERNAL_ERROR, "Codex exited")); + } + let t = state.threads.get_mut(&session).ok_or_else(|| invalid(format!("no session {session}")))?; + if t.prompt.is_some() { + return Err(invalid("a turn is already running")); + } + let cancel = t.early_cancel.take().is_some_and(|at| at.elapsed() < EARLY_CANCEL); + t.prompt = Some(PendingPrompt { done, turn: None, cancel }); + t.error = None; + t.settings.clone() + }; + let mut request = json!({ "threadId": session, "input": input, "summary": "auto" }); + if let Some(s) = settings { + request["model"] = json!(s.model_slug(&models)); + request["approvalPolicy"] = json!(s.mode.approval_policy()); + request["sandboxPolicy"] = s.mode.sandbox_policy(); + if let Some(effort) = &s.effort { + request["effort"] = json!(effort); + } + } + match self.call("turn/start", request).await { + Ok(started) => { + if let Some(turn) = started.pointer("/turn/id").and_then(Value::as_str) { + self.turn_known(&session, turn, false); + } + } + Err(e) => { + if let Some(t) = self.state.lock().threads.get_mut(&session) { + t.prompt = None; + } + return Err(e); + } + } + let (turn, last_error) = ended.await.map_err(|_| error(codes::INTERNAL_ERROR, "Codex exited"))?; + let rate_limits = self.state.lock().rate_limits.clone(); + stop_reason(&turn, last_error, rate_limits.as_ref()) + } + + async fn steer(&self, params: &Value) -> Result { + let session = str_param(params, "sessionId")?; + let turn = self.state.lock().threads.get(session).and_then(|t| t.turn.clone()); + let turn = turn.ok_or_else(|| invalid("no turn is running"))?; + let input = translate::user_input(params.get("prompt").unwrap_or(&Value::Null)); + self.call("turn/steer", json!({ "threadId": session, "expectedTurnId": turn, "input": input })).await?; + Ok(json!({})) + } + + async fn rollback(&self, params: &Value) -> Result { + let session = str_param(params, "sessionId")?; + let turns = params.get("turns").and_then(Value::as_u64).filter(|n| *n > 0).ok_or_else(|| invalid("turns"))?; + self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await?; + Ok(json!({})) + } + + /// Stops following the session's thread (codex unloads it once nothing follows it). + async fn close(&self, params: &Value) -> Result { + let session = str_param(params, "sessionId")?; + let known = self.state.lock().threads.remove(session).is_some(); + if known && let Err(e) = self.call("thread/unsubscribe", json!({ "threadId": session })).await { + tracing::debug!("codex: unsubscribing from {session}: {}", e.message); + } + Ok(json!({})) + } + + /// `session/cancel`: interrupts the session's turn, or the one about to start. + fn cancel(&self, session: &str) { + let mut state = self.state.lock(); + let Some(t) = state.threads.get_mut(session) else { return }; + match t.turn.clone() { + Some(turn) => self.interrupt(session, &turn), + None => match t.prompt.as_mut() { + Some(p) => p.cancel = true, + None => t.early_cancel = Some(Instant::now()), + }, + } + } + + fn interrupt(&self, session: &str, turn: &str) { + let codex = self.codex().clone(); + let params = json!({ "threadId": session, "turnId": turn }); + self.runtime.spawn(async move { + if let Err(e) = codex.request::<_, Value>("turn/interrupt", params, None).await { + tracing::warn!("codex: interrupting the turn failed: {e}"); + } + }); + } + + /// Turn `turn` of `session` is known: from `turn/start`'s answer, or `started` with + /// `turn/started`. Codex rejects an interrupt until it has started the turn, so a + /// stop that came earlier waits for that. + fn turn_known(&self, session: &str, turn: &str, started: bool) { + let mut state = self.state.lock(); + let Some(t) = state.threads.get_mut(session) else { return }; + if t.completed.as_deref() == Some(turn) { + return; + } + if started { + t.turn = Some(turn.to_owned()); + } + let Some(p) = t.prompt.as_mut() else { return }; + if p.turn.is_none() { + p.turn = Some(turn.to_owned()); + } + if started && p.turn.as_deref() == Some(turn) && std::mem::take(&mut p.cancel) { + self.interrupt(session, turn); + } + } + + fn codex_gone(&self) { + let mut state = self.state.lock(); + state.gone = true; + for t in state.threads.values_mut() { + t.prompt = None; + } + } + + fn emit(&self, session: &str, update: Value) { + if let Some(client) = self.client.get() { + let _ = client.notify("session/update", json!({ "sessionId": session, "update": update })); + } + } + + fn on_codex_notification(&self, method: &str, params: &Value) { + let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default(); + match method { + "turn/started" => { + if let Some(turn) = params.pointer("/turn/id").and_then(Value::as_str) { + self.turn_known(thread, turn, true); + } + self.subagent_state(thread, json!({ "state": "running" })); + } + "turn/completed" => self.turn_completed(thread, params.get("turn").unwrap_or(&Value::Null)), + "item/started" => self.item(thread, params.get("item").unwrap_or(&Value::Null), false), + "item/completed" => self.item(thread, params.get("item").unwrap_or(&Value::Null), true), + "item/agentMessage/delta" | "item/plan/delta" => self.text(thread, params, "agent_message_chunk", false), + "item/reasoning/summaryTextDelta" => self.text(thread, params, "agent_thought_chunk", false), + "item/reasoning/textDelta" => self.text(thread, params, "agent_thought_chunk", true), + "item/reasoning/summaryPartAdded" => { + if params.get("summaryIndex").and_then(Value::as_u64).unwrap_or(0) > 0 { + let mut p = params.clone(); + p["delta"] = json!("\n\n"); + self.text(thread, &p, "agent_thought_chunk", false); + } + } + "item/commandExecution/outputDelta" => self.command_output(thread, params), + "item/fileChange/patchUpdated" => { + let id = params.get("itemId").and_then(Value::as_str).unwrap_or_default(); + let item = json!({ "id": id, "type": "fileChange", "changes": params.get("changes") }); + self.with_thread(thread, |t, cwd| { + let mut update = translate::tool_call(&item, true, cwd, None)?; + update.as_object_mut()?.remove("status"); + t.last_chunk = None; + Some(update) + }); + } + "turn/plan/updated" => self.with_thread(thread, |t, _| { + t.last_chunk = None; + Some(translate::plan(params)) + }), + "thread/tokenUsage/updated" => self.with_thread(thread, |_, _| translate::usage(params)), + "thread/name/updated" => self.with_thread(thread, |_, _| { + let title = params.get("threadName").and_then(Value::as_str)?; + Some(json!({ "sessionUpdate": "session_info_update", "title": title })) + }), + "thread/started" => self.thread_started(params.get("thread").unwrap_or(&Value::Null)), + "error" => { + if params.get("willRetry").and_then(Value::as_bool) != Some(true) + && let Some(t) = self.state.lock().threads.get_mut(thread) + { + t.error = params.get("error").cloned(); + } + } + "account/rateLimits/updated" => self.state.lock().rate_limits = params.get("rateLimits").cloned(), + _ => {} + } + } + + /// Sends what `f` makes of the known thread `thread` (given its cwd), under the lock so + /// updates keep their order. + fn with_thread(&self, thread: &str, f: impl FnOnce(&mut Thread, Option<&str>) -> Option) { + let mut state = self.state.lock(); + let Some(t) = state.threads.get_mut(thread) else { return }; + let cwd = t.cwd.clone(); + if let Some(update) = f(t, cwd.as_deref()) { + self.emit(thread, update); + } + } + + /// A streamed piece of a message or reasoning item. `raw` reasoning text is used only + /// for items without a summary. + fn text(&self, thread: &str, params: &Value, kind: &'static str, raw: bool) { + let item = params.get("itemId").and_then(Value::as_str).unwrap_or_default(); + let Some(delta) = params.get("delta").and_then(Value::as_str).filter(|d| !d.is_empty()) else { return }; + self.with_thread(thread, |t, _| { + let summarized = t.streamed.contains(&format!("summary:{item}")); + if raw && summarized { + return None; + } + if !raw && kind == "agent_thought_chunk" { + t.streamed.insert(format!("summary:{item}")); + } + t.streamed.insert(item.to_owned()); + Some(t.chunk(kind, item, delta)) + }); + } + + fn command_output(&self, thread: &str, params: &Value) { + let item = params.get("itemId").and_then(Value::as_str).unwrap_or_default(); + let delta = params.get("delta").and_then(Value::as_str).unwrap_or_default(); + self.with_thread(thread, |t, _| { + let out = t + .outputs + .entry(item.to_owned()) + .or_insert_with(|| Output { text: String::new(), sent: Instant::now() - OUTPUT_EVERY }); + out.text.push_str(delta); + if out.text.len() > 2 * translate::MAX_OUTPUT { + out.text = translate::tail(&out.text); + } + if out.sent.elapsed() < OUTPUT_EVERY { + return None; + } + out.sent = Instant::now(); + t.last_chunk = None; + Some(translate::command_output(item, &out.text)) + }); + } + + fn item(&self, thread: &str, item: &Value, done: bool) { + let id = item.get("id").and_then(Value::as_str).unwrap_or_default().to_owned(); + match item.get("type").and_then(Value::as_str).unwrap_or_default() { + "userMessage" | "hookPrompt" => {} + ty @ ("agentMessage" | "plan" | "reasoning") => { + if !done { + return; + } + let (kind, text) = if ty == "reasoning" { + ("agent_thought_chunk", translate::reasoning_text(item)) + } else { + ("agent_message_chunk", item.get("text").and_then(Value::as_str).unwrap_or_default().to_owned()) + }; + self.with_thread(thread, |t, _| { + if ty == "agentMessage" { + t.answer = text.clone(); + } + t.streamed.remove(&format!("summary:{id}")); + if t.streamed.remove(&id) || text.is_empty() { + return None; + } + Some(t.chunk(kind, &id, &text)) + }); + } + "subAgentActivity" => { + let child = item.get("agentThreadId").and_then(Value::as_str).unwrap_or_default(); + let name = item.get("agentPath").and_then(Value::as_str).and_then(|p| p.rsplit('/').next()); + if !done && item.get("kind").and_then(Value::as_str) == Some("started") { + self.spawned(thread, child, name, None, &id); + } + } + "collabAgentToolCall" if item.get("tool").and_then(Value::as_str) == Some("spawnAgent") => { + if !done { + return; + } + let prompt = item.get("prompt").and_then(Value::as_str); + for child in item.get("receiverThreadIds").and_then(Value::as_array).into_iter().flatten() { + if let Some(child) = child.as_str() { + self.spawned(thread, child, None, prompt, &id); + } + } + } + _ => self.with_thread(thread, |t, cwd| { + let output = if done { t.outputs.remove(&id).map(|o| o.text) } else { None }; + let update = translate::tool_call(item, done, cwd, output.as_deref())?; + if done { + t.items.remove(&id); + } else { + t.items.insert(id.clone(), item.clone()); + } + t.last_chunk = None; + Some(update) + }), + } + } + + /// A subagent thread `child` started by session `parent`: announced on the parent, with + /// what it was asked. + fn spawned(&self, parent: &str, child: &str, name: Option<&str>, prompt: Option<&str>, call: &str) { + if child.is_empty() { + return; + } + let mut state = self.state.lock(); + if !state.threads.contains_key(parent) { + return; + } + let cwd = state.threads.get(parent).and_then(|t| t.cwd.clone()); + let known = state.threads.contains_key(child); + let t = state.threads.entry(child.to_owned()).or_default(); + t.parent = Some(parent.to_owned()); + t.cwd = t.cwd.take().or(cwd); + if !known { + let mut announce = json!({ "sessionUpdate": "subagent_update", "sessionId": child, + "state": { "state": "running" }, "capabilities": { "cancel": {} } }); + if let Some(name) = name { + announce["title"] = json!(name); + } + self.emit(parent, announce); + } + if let Some(prompt) = prompt.filter(|p| !p.is_empty()) { + if known { + self.emit( + parent, + json!({ "sessionUpdate": "subagent_update", "sessionId": child, "description": prompt }), + ); + } + self.emit( + parent, + json!({ "sessionUpdate": "session_message", "messageId": call, "senderSessionId": parent, + "recipientSessionId": child, "content": [{ "type": "text", "text": prompt }] }), + ); + } + } + + /// A thread codex started on its own: a subagent when its parent is a session here. + fn thread_started(&self, thread: &Value) { + let (Some(id), Some(parent)) = + (thread.get("id").and_then(Value::as_str), thread.get("parentThreadId").and_then(Value::as_str)) + else { + return; + }; + let name = thread.get("agentNickname").or(thread.get("agentRole")).and_then(Value::as_str); + let known = self.state.lock().threads.contains_key(id); + self.spawned(parent, id, name, None, ""); + if known && let Some(name) = name { + self.emit(parent, json!({ "sessionUpdate": "subagent_update", "sessionId": id, "title": name })); + } + } + + /// Reports a subagent thread's state on its parent. + fn subagent_state(&self, thread: &str, state_of: Value) { + let parent = self.state.lock().threads.get(thread).and_then(|t| t.parent.clone()); + if let Some(parent) = parent { + self.emit( + parent.as_str(), + json!({ "sessionUpdate": "subagent_update", "sessionId": thread, "state": state_of }), + ); + } + } + + fn turn_completed(&self, thread: &str, turn: &Value) { + let id = turn.get("id").and_then(Value::as_str).unwrap_or_default(); + let (parent, answer) = { + let mut state = self.state.lock(); + let Some(t) = state.threads.get_mut(thread) else { return }; + if t.turn.as_deref().is_none_or(|r| r == id) { + t.turn = None; + } + t.completed = Some(id.to_owned()); + t.items.clear(); + t.last_chunk = None; + let error = t.error.take(); + if t.prompt.as_ref().is_some_and(|p| p.turn.as_deref().is_none_or(|p| p == id)) + && let Some(p) = t.prompt.take() + { + let _ = p.done.send((turn.clone(), error.clone())); + } + let answer = std::mem::take(&mut t.answer); + (t.parent.clone(), (answer, error)) + }; + let Some(parent) = parent else { return }; + let (answer, error) = answer; + let mut state_of = match turn.get("status").and_then(Value::as_str) { + Some("interrupted") => json!({ "state": "idle", "stopReason": "cancelled" }), + Some("failed") => json!({ "state": "idle", "stopReason": "error" }), + _ => json!({ "state": "idle", "stopReason": "end_turn" }), + }; + if let Some(e) = turn.get("error").filter(|e| !e.is_null()).or(error.as_ref()) { + state_of["error"] = json!({ "code": codes::INTERNAL_ERROR, "message": translate::error_message(e) }); + } + self.emit(&parent, json!({ "sessionUpdate": "subagent_update", "sessionId": thread, "state": state_of })); + if !answer.is_empty() { + self.emit( + &parent, + json!({ "sessionUpdate": "session_message", "messageId": format!("{thread}:{id}"), + "senderSessionId": thread, "recipientSessionId": parent, + "content": [{ "type": "text", "text": answer }] }), + ); + } + } + + /// Requests from codex: approvals and questions, asked as ACP permission requests. + fn on_codex_request(&self, method: &str, params: &Value) -> Result { + let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default().to_owned(); + let item_id = params.get("itemId").and_then(Value::as_str).unwrap_or_default().to_owned(); + let (item, cwd) = { + let state = self.state.lock(); + let t = state.threads.get(&thread); + (t.and_then(|t| t.items.get(&item_id).cloned()), t.and_then(|t| t.cwd.clone())) + }; + let cwd = cwd.as_deref(); + let reason = params.get("reason").and_then(Value::as_str).filter(|r| !r.is_empty()); + let decline = json!({ "decision": "decline" }); + match method { + "item/commandExecution/requestApproval" => { + let choices = translate::decisions(params, true); + let title = translate::command_approval_title(params, item.as_ref(), cwd); + let call = tool_call_of( + &item_id, + &title, + "execute", + reason, + Some(json!({ "command": params.get("command"), "cwd": params.get("cwd") })), + ); + Ok(self.ask(&thread, call, &choices, decline)) + } + "item/fileChange/requestApproval" => { + let choices = translate::decisions(params, false); + let title = translate::file_approval_title(item.as_ref(), cwd); + let mut call = tool_call_of(&item_id, &title, "edit", reason, None); + if let Some(content) = item + .as_ref() + .and_then(|i| translate::tool_call(i, false, cwd, None)) + .and_then(|c| c.get("content").cloned()) + { + call["content"] = content; + } + Ok(self.ask(&thread, call, &choices, decline)) + } + "item/permissions/requestApproval" => { + let requested = params.get("permissions").cloned().unwrap_or(json!({})); + let choice = |id: &str, name: &str, kind: &str, answer: Value| Choice { + option: json!({ "optionId": id, "name": name, "kind": kind }), + answer, + }; + let choices = [ + choice( + "turn", + "Allow for this turn", + "allow_once", + json!({ "permissions": requested, "scope": "turn" }), + ), + choice( + "session", + "Allow for this session", + "allow_always", + json!({ "permissions": requested, "scope": "session" }), + ), + choice("decline", "Decline", "reject_once", json!({ "permissions": {}, "scope": "turn" })), + ]; + let title = reason.map_or_else(|| "Allow more access".to_owned(), str::to_owned); + let call = tool_call_of(&item_id, &title, "other", None, Some(requested.clone())); + Ok(self.ask(&thread, call, &choices, json!({ "permissions": {}, "scope": "turn" }))) + } + "item/tool/requestUserInput" => { + let mut answers = serde_json::Map::new(); + for q in params.get("questions").and_then(Value::as_array).into_iter().flatten() { + let Some(qid) = q.get("id").and_then(Value::as_str) else { continue }; + let choices: Vec = q + .get("options") + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + .filter_map(|(i, o)| { + let label = o.get("label")?.as_str()?; + Some(Choice { + option: json!({ "optionId": i.to_string(), "name": label, "kind": "allow_once" }), + answer: json!({ "answers": [label] }), + }) + }) + .collect(); + if choices.is_empty() { + continue; + } + let title = q.get("question").and_then(Value::as_str).unwrap_or("Codex has a question"); + let call = tool_call_of(&item_id, title, "other", None, None); + let answer = self.ask(&thread, call, &choices, Value::Null); + if !answer.is_null() { + answers.insert(qid.to_owned(), answer); + } + } + Ok(json!({ "answers": answers })) + } + "mcpServer/elicitation/request" => { + let server = params.get("serverName").and_then(Value::as_str).unwrap_or("An MCP server"); + let message = params.get("message").and_then(Value::as_str).unwrap_or_default(); + let accept = match params.get("mode").and_then(Value::as_str) { + Some("url") => json!({ "action": "accept" }), + _ => json!({ "action": "accept", "content": {} }), + }; + let choices = [ + Choice { + option: json!({ "optionId": "accept", "name": "Accept", "kind": "allow_once" }), + answer: accept, + }, + Choice { + option: json!({ "optionId": "decline", "name": "Decline", "kind": "reject_once" }), + answer: json!({ "action": "decline" }), + }, + ]; + let mut title = format!("{server}: {message}"); + if let Some(url) = params.get("url").and_then(Value::as_str) { + title.push_str(&format!(" ({url})")); + } + let call = tool_call_of(&format!("elicitation:{server}"), &title, "other", None, None); + Ok(self.ask(&thread, call, &choices, json!({ "action": "decline" }))) + } + _ => Err((codes::METHOD_NOT_FOUND, format!("unsupported request {method}"))), + } + } + + /// Asks the client; returns the answer for codex (`otherwise` when cancelled). + fn ask(&self, session: &str, tool_call: Value, choices: &[Choice], otherwise: Value) -> Value { + let Some(client) = self.client.get().cloned() else { return otherwise }; + let options: Vec = choices.iter().map(|c| c.option.clone()).collect(); + let params = json!({ "sessionId": session, "toolCall": tool_call, "options": options }); + let outcome: Result = + self.runtime.block_on(client.request("session/request_permission", params, None)); + match outcome { + Ok(outcome) => translate::answer_of(choices, &outcome, otherwise), + Err(e) => { + tracing::debug!("codex: permission request failed: {e}"); + otherwise + } + } + } +} + +impl Thread { + /// A text chunk of `item`; a new item of the same kind right after another starts a + /// new paragraph. + fn chunk(&mut self, kind: &'static str, item: &str, text: &str) -> Value { + let text = match &self.last_chunk { + Some((k, i)) if *k == kind && i != item => format!("\n\n{text}"), + _ => text.to_owned(), + }; + self.last_chunk = Some((kind, item.to_owned())); + translate::chunk(kind, &text) + } +} + +/// The ACP `toolCall` of a permission request. +fn tool_call_of(id: &str, title: &str, kind: &str, reason: Option<&str>, raw: Option) -> Value { + let mut call = json!({ "toolCallId": id, "title": title, "kind": kind, "status": "pending" }); + if let Some(reason) = reason { + call["content"] = json!([{ "type": "content", "content": { "type": "text", "text": reason } }]); + } + if let Some(raw) = raw { + call["rawInput"] = raw; + } + call +} + +/// The `session/prompt` answer for a turn that ended. +fn stop_reason(turn: &Value, last_error: Option, rate_limits: Option<&Value>) -> Result { + let stop = match turn.get("status").and_then(Value::as_str) { + Some("interrupted") => "cancelled", + Some("failed") => { + let e = turn.get("error").filter(|e| !e.is_null()).cloned().or(last_error).unwrap_or(json!({})); + let message = translate::error_message(&e); + return match translate::classify(&e, rate_limits) { + Failure::Refusal => Ok(json!({ "stopReason": "refusal" })), + Failure::UsageLimit(resets) => Err(ResponseError { + code: codes::INTERNAL_ERROR, + message, + data: Some(json!({ "openidea": { "usageLimit": { "resetsAtMs": resets } } })), + }), + Failure::Auth => Err(error(AUTH_REQUIRED, format!("Codex authentication failed: {message}"))), + Failure::Other => Err(error(codes::INTERNAL_ERROR, message)), + }; + } + _ => "end_turn", + }; + Ok(json!({ "stopReason": stop })) +} diff --git a/crates/ide-agent/src/native/codex/tests.rs b/crates/ide-agent/src/native/codex/tests.rs new file mode 100644 index 000000000..f98f6921a --- /dev/null +++ b/crates/ide-agent/src/native/codex/tests.rs @@ -0,0 +1,872 @@ +//! The Codex shim against codex transcripts: frames recorded from the real +//! `codex app-server` (`record_codex_fixtures`, ignored) or written by hand where a +//! recording isn't possible, played back by a fake codex. +//! +//! A transcript is JSONL: a `{"type":"meta",…}` line, then `{"dir":"out","frame":…}` for +//! each frame the shim sent and `{"dir":"in","frame":…}` for each frame codex sent. + +use super::translate::*; +use super::*; +use std::collections::VecDeque; +use std::path::PathBuf; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; + +// ---- Pure translations ------------------------------------------------------------- + +fn models() -> Vec { + models_of(&json!({"data": [ + {"id": "big", "model": "big", "displayName": "Big", "description": "d", "hidden": false, "isDefault": true, + "defaultReasoningEffort": "low", + "supportedReasoningEfforts": [{"reasoningEffort": "low", "description": "fast"}, {"reasoningEffort": "xhigh", "description": ""}]}, + {"id": "plain", "model": "plain-slug", "displayName": "", "description": "", "hidden": false, "isDefault": false, + "supportedReasoningEfforts": []}, + {"id": "secret", "model": "secret", "displayName": "S", "description": "", "hidden": true, "isDefault": false, + "supportedReasoningEfforts": []} + ]})) +} + +#[test] +fn settings_follow_the_thread_and_the_models() { + let models = models(); + let result = json!({"model": "big", "reasoningEffort": null, "approvalPolicy": "untrusted", + "sandbox": {"type": "workspaceWrite"}}); + let mut s = Settings::of(&result, &models); + assert_eq!(s, Settings { model: "big".into(), effort: Some("low".into()), mode: Mode::Ask }); + + let options = config_options(&models, &s); + let ids: Vec<&str> = options.as_array().unwrap().iter().map(|o| o["id"].as_str().unwrap()).collect(); + assert_eq!(ids, [MODEL, EFFORT, MODE]); + assert_eq!(options[0]["options"].as_array().unwrap().len(), 2, "hidden models are left out"); + assert_eq!(options[1]["category"], "thought_level"); + assert_eq!(options[1]["options"][1]["name"], "Extra high"); + assert_eq!(options[2]["currentValue"], "ask"); + + // A model without efforts drops the option; its slug is what codex gets. + s.set(&models, MODEL, "plain").unwrap(); + assert_eq!((s.effort.as_deref(), s.model_slug(&models).as_str()), (None, "plain-slug")); + assert_eq!(config_options(&models, &s).as_array().unwrap().len(), 2); + assert!(s.set(&models, EFFORT, "low").is_err()); + assert!(s.set(&models, MODEL, "nope").is_err()); + s.set(&models, MODE, "full-access").unwrap(); + assert_eq!((s.mode.approval_policy(), s.mode.sandbox_policy()), ("never", json!({"type": "dangerFullAccess"}))); + + // A model codex runs that it doesn't list is still offered. + let s = Settings::of(&json!({"model": "custom", "sandbox": {"type": "readOnly"}}), &models); + assert_eq!(s.mode, Mode::ReadOnly); + let options = config_options(&models, &s); + assert_eq!(options[0]["options"][2], json!({"value": "custom", "name": "custom"})); +} + +#[test] +fn prompts_become_user_input() { + let input = user_input(&json!([ + {"type": "text", "text": "fix it"}, + {"type": "resource", "resource": {"uri": "file:///p/a%20b.rs", "text": "fn x() {}"}}, + {"type": "resource_link", "uri": "file:///p/big.bin", "name": "big.bin"}, + {"type": "image", "mimeType": "image/jpeg", "data": "AAA"}, + {"type": "audio", "data": "x"} + ])); + assert_eq!( + input, + vec![ + json!({"type": "text", "text": "fix it"}), + json!({"type": "text", "text": "\nfn x() {}\n"}), + json!({"type": "text", "text": "/p/big.bin"}), + json!({"type": "image", "url": "data:image/jpeg;base64,AAA"}), + ] + ); +} + +#[test] +fn commands_are_titled_by_what_they_do() { + let cmd = |actions: Value| { + json!({"id": "c", "type": "commandExecution", "command": "/bin/zsh -lc 'x'", "cwd": "/p", + "status": "inProgress", "commandActions": actions}) + }; + let call = tool_call( + &cmd(json!([{"type": "read", "command": "cat a.rs", "name": "a.rs", "path": "/p/a.rs"}])), + false, + Some("/p"), + None, + ) + .unwrap(); + assert_eq!((call["title"].as_str(), call["kind"].as_str()), (Some("Read a.rs"), Some("read"))); + assert_eq!(call["locations"], json!([{"path": "/p/a.rs"}])); + let call = tool_call( + &cmd(json!([{"type": "search", "command": "rg x src", "query": "x", "path": "/p/src"}])), + false, + Some("/p"), + None, + ) + .unwrap(); + assert_eq!(call["title"], "Search x in src"); + let call = tool_call(&cmd(json!([{"type": "unknown", "command": "cargo test"}])), false, None, Some("ok")).unwrap(); + assert_eq!( + (call["title"].as_str(), call["kind"].as_str(), call["status"].as_str()), + (Some("cargo test"), Some("execute"), Some("in_progress")) + ); + assert_eq!(call["content"][0]["content"]["text"], "ok"); + + let mut done = cmd(json!([])); + done["status"] = json!("declined"); + done["exitCode"] = json!(1); + let call = tool_call(&done, true, None, None).unwrap(); + assert_eq!((call["sessionUpdate"].as_str(), call["status"].as_str()), (Some("tool_call_update"), Some("failed"))); + assert_eq!(call["title"], "/bin/zsh -lc 'x'"); + assert_eq!(call["rawOutput"], json!({"exitCode": 1})); +} + +#[test] +fn file_changes_carry_diffs() { + let item = json!({"id": "f", "type": "fileChange", "status": "completed", "changes": [ + {"path": "/p/new.txt", "kind": {"type": "add"}, "diff": "hello\n"}, + {"path": "/p/a.rs", "kind": {"type": "update", "move_path": null}, + "diff": "@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n@@ -9 +9,2 @@\n-z\n+y\n+w\n\\ No newline at end of file\n"}, + {"path": "/p/old.rs", "kind": {"type": "delete"}, "diff": "gone\n"} + ]}); + let call = tool_call(&item, true, Some("/p"), None).unwrap(); + assert_eq!( + (call["title"].as_str(), call["kind"].as_str(), call["status"].as_str()), + (Some("Edit 3 files"), Some("edit"), Some("completed")) + ); + assert_eq!( + call["content"], + json!([ + {"type": "diff", "path": "/p/new.txt", "oldText": "", "newText": "hello\n"}, + {"type": "diff", "path": "/p/a.rs", "oldText": "a\nb\nc\nz\n", "newText": "a\nB\nc\ny\nw\n"}, + {"type": "diff", "path": "/p/old.rs", "oldText": "gone\n", "newText": ""} + ]) + ); + let one = json!({"id": "f", "type": "fileChange", "status": "inProgress", "changes": [ + {"path": "/p/a.rs", "kind": {"type": "update", "move_path": "/p/b.rs"}, "diff": "@@ -1 +1 @@\n-x\n+y\n"}]}); + let call = tool_call(&one, false, Some("/p"), None).unwrap(); + assert_eq!((call["title"].as_str(), call["kind"].as_str()), (Some("Move a.rs to b.rs"), Some("move"))); + assert_eq!(call["content"][0]["path"], "/p/b.rs"); + assert_eq!(file_approval_title(Some(&one), Some("/p")), "Move a.rs to b.rs"); +} + +#[test] +fn other_items() { + let search = json!({"id": "w", "type": "webSearch", "query": "", "action": null}); + assert_eq!(tool_call(&search, false, None, None).unwrap()["title"], "Search the web"); + let search = + json!({"id": "w", "type": "webSearch", "query": "rust", "action": {"type": "search", "query": "rust"}}); + let call = tool_call(&search, true, None, None).unwrap(); + assert_eq!( + (call["title"].as_str(), call["status"].as_str(), call["kind"].as_str()), + (Some("Search the web: rust"), Some("completed"), Some("fetch")) + ); + let mcp = json!({"id": "m", "type": "mcpToolCall", "server": "gh", "tool": "issue", "status": "failed", + "arguments": {"n": 1}, "result": {"content": [{"type": "text", "text": "partial"}]}, "error": {"message": "boom"}}); + let call = tool_call(&mcp, true, None, None).unwrap(); + assert_eq!(call["title"], "gh: issue"); + assert_eq!(call["content"][1]["content"]["text"], "boom"); + assert_eq!(call["rawInput"], json!({"n": 1})); + for skipped in ["agentMessage", "reasoning", "userMessage", "subAgentActivity"] { + assert!(tool_call(&json!({"id": "x", "type": skipped}), false, None, None).is_none()); + } + let spawn = json!({"id": "x", "type": "collabAgentToolCall", "tool": "spawnAgent", "status": "completed"}); + assert!(tool_call(&spawn, true, None, None).is_none()); + + let plan = plan(&json!({"plan": [{"step": "a", "status": "completed"}, {"step": "b", "status": "inProgress"}]})); + assert_eq!(plan["entries"][1], json!({"content": "b", "priority": "medium", "status": "in_progress"})); + let u = usage( + &json!({"tokenUsage": {"last": {"totalTokens": 20}, "total": {"totalTokens": 99}, "modelContextWindow": 1000}}), + ); + assert_eq!(u, Some(json!({"sessionUpdate": "usage_update", "used": 20, "size": 1000}))); + assert!(usage(&json!({"tokenUsage": {"last": {"totalTokens": 20}, "modelContextWindow": null}})).is_none()); + assert_eq!(reasoning_text(&json!({"summary": ["a", "b"], "content": ["raw"]})), "a\n\nb"); + assert!(tail(&"é".repeat(MAX_OUTPUT)).starts_with('…')); +} + +#[test] +fn failures_are_classified() { + let limits = json!({"primary": {"usedPercent": 100, "resetsAt": 1_800_000_000}, "secondary": {"usedPercent": 40, "resetsAt": 1_900_000_000}}); + let e = json!({"message": "You've hit your usage limit.", "codexErrorInfo": "usageLimitExceeded"}); + assert_eq!(classify(&e, Some(&limits)), Failure::UsageLimit(Some(1.8e12))); + assert_eq!(classify(&e, None), Failure::UsageLimit(None)); + let e = json!({"message": "x", "codexErrorInfo": {"responseTooManyFailedAttempts": {"httpStatusCode": 429}}}); + assert_eq!(classify(&e, None), Failure::UsageLimit(None)); + assert_eq!(classify(&json!({"message": "x", "codexErrorInfo": "unauthorized"}), None), Failure::Auth); + let e = json!({"message": "unexpected status 401 Unauthorized: Missing bearer", "codexErrorInfo": "other"}); + assert_eq!(classify(&e, None), Failure::Auth); + assert_eq!(classify(&json!({"message": "no", "codexErrorInfo": "cyberPolicy"}), None), Failure::Refusal); + assert_eq!( + classify(&json!({"message": "too long", "codexErrorInfo": "contextWindowExceeded"}), None), + Failure::Other + ); + assert_eq!(error_message(&json!({"message": "Failed", "additionalDetails": "because"})), "Failed\nbecause"); + + let usage_limit = stop_reason( + &json!({"status": "failed", "error": null}), + Some(json!({"message": "limit", "codexErrorInfo": "usageLimitExceeded"})), + Some(&limits), + ) + .unwrap_err(); + assert_eq!(usage_limit.data, Some(json!({"openidea": {"usageLimit": {"resetsAtMs": 1.8e12}}}))); + assert_eq!(stop_reason(&json!({"status": "interrupted"}), None, None).unwrap(), json!({"stopReason": "cancelled"})); +} + +#[test] +fn approvals_offer_what_codex_offers_and_always_a_decline() { + let params = json!({"availableDecisions": ["accept", + {"acceptWithExecpolicyAmendment": {"execpolicy_amendment": ["/bin/bash", "-lc", "make"]}}, + {"applyNetworkPolicyAmendment": {"network_policy_amendment": {"host": "crates.io", "action": "allow"}}}, + "cancel"]}); + let choices = decisions(¶ms, true); + let options: Vec<(&str, &str, &str)> = choices + .iter() + .map(|c| { + ( + c.option["optionId"].as_str().unwrap(), + c.option["name"].as_str().unwrap(), + c.option["kind"].as_str().unwrap(), + ) + }) + .collect(); + assert_eq!( + options, + [ + ("accept", "Allow once", "allow_once"), + ("acceptWithExecpolicyAmendment", "Always allow `make`", "allow_always"), + ("applyNetworkPolicyAmendment:allow:crates.io", "Always allow crates.io", "allow_always"), + ("decline", "Decline", "reject_once"), + ("cancel", "Decline and stop", "reject_once"), + ] + ); + let selected = |id: &str| json!({"outcome": {"outcome": "selected", "optionId": id}}); + let decline = json!({"decision": "decline"}); + assert_eq!( + answer_of(&choices, &selected("acceptWithExecpolicyAmendment"), decline.clone()), + json!({"decision": params["availableDecisions"][1]}) + ); + assert_eq!(answer_of(&choices, &json!({"outcome": {"outcome": "cancelled"}}), decline.clone()), decline); + assert_eq!(answer_of(&choices, &selected("zzz"), decline.clone()), decline); + let ids: Vec = decisions(&json!({}), false).iter().map(|c| c.option["optionId"].clone()).collect(); + assert_eq!(ids, [json!("accept"), json!("acceptForSession"), json!("decline"), json!("cancel")]); + assert_eq!( + command_approval_title( + &json!({"command": "/bin/zsh -lc 'ls'", "commandActions": [{"type": "unknown", "command": "touch b"}]}), + None, + None + ), + "Run touch b" + ); +} + +#[test] +fn history_replays_turns() { + let thread = json!({"turns": [{"id": "t", "status": "completed", "items": [ + {"type": "userMessage", "id": "u", "content": [{"type": "text", "text": "hi"}]}, + {"type": "reasoning", "id": "r", "summary": ["think"]}, + {"type": "agentMessage", "id": "a", "text": "hello"}, + {"type": "commandExecution", "id": "c", "command": "ls", "cwd": "/p", "status": "completed", + "commandActions": [], "aggregatedOutput": "a.txt\n", "exitCode": 0} + ]}]}); + let updates = history(&thread, Some("/p")); + let kinds: Vec<&str> = updates.iter().map(|u| u["sessionUpdate"].as_str().unwrap()).collect(); + assert_eq!( + kinds, + ["user_message_chunk", "agent_thought_chunk", "agent_message_chunk", "tool_call", "tool_call_update"] + ); + assert_eq!(updates[4]["status"], "completed"); +} + +// ---- Transcripts --------------------------------------------------------------------- + +/// The ACP client: records updates and answers permission requests in order. +#[derive(Default)] +struct Client { + updates: Mutex>, + asked: Mutex>, + /// Option ids to choose; `None` (or none left) cancels. + answers: Mutex>>, +} + +impl Handler for Client { + fn on_notification(&self, method: &str, params: Value) { + if method == "session/update" { + self.updates.lock().push(params); + } + } + + fn on_request(&self, method: &str, params: Value) -> Result { + assert_eq!(method, "session/request_permission"); + self.asked.lock().push(params); + Ok(match self.answers.lock().pop_front().flatten() { + Some(id) => json!({ "outcome": { "outcome": "selected", "optionId": id } }), + None => json!({ "outcome": { "outcome": "cancelled" } }), + }) + } +} + +impl Client { + /// The updates of `session`, as `(sessionUpdate, update)`. + fn of(&self, session: &str) -> Vec { + self.updates.lock().iter().filter(|u| u["sessionId"] == session).map(|u| u["update"].clone()).collect() + } + + /// The text of the `kind` chunks of `session`. + fn text(&self, session: &str, kind: &str) -> String { + self.of(session) + .iter() + .filter(|u| u["sessionUpdate"] == kind) + .filter_map(|u| u.pointer("/content/text").and_then(Value::as_str).map(str::to_owned)) + .collect() + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Scenario { + AuthFailure, + Interrupt, + Resume, + Steer, + Simple, + MultiTurn, + Approvals, + UsageLimit, + Subagent, +} + +impl Scenario { + fn file(self) -> &'static str { + match self { + Scenario::AuthFailure => "auth_failure", + Scenario::Interrupt => "interrupt", + Scenario::Resume => "resume", + Scenario::Steer => "steer", + Scenario::Simple => "simple", + Scenario::MultiTurn => "multi_turn", + Scenario::Approvals => "approvals", + Scenario::UsageLimit => "usage_limit", + Scenario::Subagent => "subagent", + } + } +} + +/// What a scenario's ACP requests returned, by label. +type Results = Vec<(&'static str, Result)>; + +fn result<'a>(results: &'a Results, label: &str) -> &'a Result { + &results.iter().find(|(l, _)| *l == label).unwrap_or_else(|| panic!("no {label}")).1 +} + +async fn request(acp: &Peer, method: &str, params: Value) -> Result { + acp.request(method, params, None).await +} + +fn text(t: &str) -> Value { + json!([{ "type": "text", "text": t }]) +} + +/// Drives the shim over ACP. `thread` is the thread a resume loads. +async fn drive(scenario: Scenario, acp: &Peer, client: &Client, cwd: &str, thread: Option<&str>) -> Results { + let mut out: Results = Vec::new(); + out.push(( + "initialize", + request(acp, "initialize", json!({ "protocolVersion": 1, "clientCapabilities": {} })).await, + )); + if scenario == Scenario::Resume { + let thread = thread.expect("a thread to resume"); + out.push(( + "load", + request(acp, "session/load", json!({ "sessionId": thread, "cwd": cwd, "mcpServers": [] })).await, + )); + out.push(( + "fork", + request(acp, "session/fork", json!({ "sessionId": thread, "cwd": cwd, "mcpServers": [] })).await, + )); + out.push(("rollback", request(acp, "_openidea/rollback", json!({ "sessionId": thread, "turns": 1 })).await)); + out.push(("close", request(acp, "session/close", json!({ "sessionId": thread })).await)); + return out; + } + let new = request(acp, "session/new", json!({ "cwd": cwd, "mcpServers": [] })).await; + let session = new.as_ref().ok().and_then(|v| v["sessionId"].as_str()).unwrap_or_default().to_owned(); + out.push(("new", new)); + let prompt = |t: &str| json!({ "sessionId": session, "prompt": text(t) }); + let set = |id: &str, value: &str| json!({ "sessionId": session, "configId": id, "value": value }); + match scenario { + Scenario::AuthFailure | Scenario::Simple | Scenario::UsageLimit | Scenario::Subagent => { + out.push(("prompt", request(acp, "session/prompt", prompt("Reply with the single word: hi")).await)); + } + Scenario::Interrupt => { + out.push(("mode", request(acp, "session/set_config_option", set(MODE, "read-only")).await)); + let running = { + let (acp, p) = (acp.clone(), prompt("Count to five.")); + tokio::spawn(async move { request(&acp, "session/prompt", p).await }) + }; + acp.notify("session/cancel", json!({ "sessionId": session })).unwrap(); + out.push(("prompt", running.await.unwrap())); + } + Scenario::Steer => { + let running = { + let (acp, p) = (acp.clone(), prompt("Count to five.")); + tokio::spawn(async move { request(&acp, "session/prompt", p).await }) + }; + let mut steered = Err(RpcError::Timeout); + for _ in 0..200 { + steered = request( + acp, + "_openidea/steer", + json!({ "sessionId": session, "prompt": text("Count to three instead.") }), + ) + .await; + match &steered { + Err(RpcError::Server { message, .. }) if message.contains("no turn") => { + tokio::time::sleep(Duration::from_millis(20)).await; + } + _ => break, + } + } + out.push(("steer", steered)); + acp.notify("session/cancel", json!({ "sessionId": session })).unwrap(); + out.push(("prompt", running.await.unwrap())); + } + Scenario::MultiTurn => { + out.push(("first", request(acp, "session/prompt", prompt("Reply with: one")).await)); + out.push(("model", request(acp, "session/set_config_option", set(MODEL, "gpt-5.5")).await)); + out.push(("effort", request(acp, "session/set_config_option", set(EFFORT, "high")).await)); + out.push(("second", request(acp, "session/prompt", prompt("Reply with: two")).await)); + } + Scenario::Approvals => { + client.answers.lock().extend([Some("accept"), None]); + out.push(("prompt", request(acp, "session/prompt", prompt("Run `touch b.txt`, then write a.txt.")).await)); + } + Scenario::Resume => unreachable!(), + } + out +} + +struct Transcript { + meta: Value, + /// `(sent by the shim, frame)`. + frames: Vec<(bool, Value)>, +} + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/codex") +} + +fn load(name: &str) -> Transcript { + let text = std::fs::read_to_string(fixture_dir().join(format!("{name}.jsonl"))).unwrap(); + let mut lines = text.lines().filter(|l| !l.trim().is_empty()).map(|l| serde_json::from_str::(l).unwrap()); + let meta = lines.next().unwrap(); + assert_eq!(meta["type"], "meta"); + let frames = lines.map(|l| (l["dir"] == "out", l["frame"].clone())).collect(); + Transcript { meta, frames } +} + +/// Plays codex: waits for each frame the shim should send, sends what codex sent. +/// Returns the frames the shim sent. +async fn play( + t: &Transcript, + read: R, + mut write: W, +) -> (Vec, R, W) { + let mut lines = BufReader::new(read).lines(); + let mut ids: HashMap = HashMap::new(); + let mut sent = Vec::new(); + for (out, frame) in &t.frames { + if *out { + let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line()) + .await + .unwrap_or_else(|_| panic!("the shim didn't send {frame}")) + .unwrap() + .unwrap_or_else(|| panic!("the shim closed before {frame}")); + let actual: Value = serde_json::from_str(&line).unwrap(); + match frame.get("method") { + Some(method) => { + assert_eq!(actual.get("method"), Some(method), "expected {frame}, got {actual}"); + if let Some(id) = frame.get("id") { + ids.insert(id.to_string(), actual["id"].clone()); + } + } + None => { + assert_eq!(actual.get("id"), frame.get("id"), "expected {frame}, got {actual}"); + assert_eq!(actual.get("result"), frame.get("result"), "expected {frame}, got {actual}"); + } + } + sent.push(actual); + } else { + let mut frame = frame.clone(); + if frame.get("method").is_none() + && let Some(id) = frame.get("id").and_then(|id| ids.get(&id.to_string())) + { + frame["id"] = id.clone(); + } + let mut line = serde_json::to_vec(&frame).unwrap(); + line.push(b'\n'); + write.write_all(&line).await.unwrap(); + } + } + (sent, lines.into_inner().into_inner(), write) +} + +/// Runs `scenario` against its transcript; returns the ACP results, the client and +/// the frames the shim sent. +async fn replay(scenario: Scenario) -> (Results, Arc, Vec, Value) { + let t = load(scenario.file()); + let (ours, theirs) = tokio::io::duplex(1 << 20); + let (tr, tw) = tokio::io::split(theirs); + let client = Arc::new(Client::default()); + let acp = attach(client.clone(), |events| { + let (or, ow) = tokio::io::split(ours); + Ok(Peer::connect_framed(or, ow, events, Framing::Lines)) + }) + .unwrap(); + let meta = t.meta.clone(); + let player = tokio::spawn(async move { play(&t, tr, tw).await }); + let thread = meta["thread"].as_str(); + let results = tokio::time::timeout(Duration::from_secs(20), drive(scenario, &acp, &client, "/work", thread)) + .await + .expect("the scenario finished"); + // Codex stays up until the scenario is over. + let (sent, _read, _write) = player.await.unwrap(); + (results, client, sent, meta) +} + +fn sent<'a>(frames: &'a [Value], method: &str) -> Vec<&'a Value> { + frames.iter().filter(|f| f["method"] == method).collect() +} + +fn session_of(results: &Results, label: &str) -> String { + result(results, label).as_ref().unwrap()["sessionId"].as_str().unwrap().to_owned() +} + +#[tokio::test(flavor = "multi_thread")] +async fn auth_failure_names_auth() { + let (results, _client, frames, _) = replay(Scenario::AuthFailure).await; + let init = result(&results, "initialize").as_ref().unwrap(); + assert_eq!(init["agentCapabilities"]["loadSession"], true); + assert_eq!(init["agentCapabilities"]["_meta"]["openidea"], json!({"rollback": true, "steer": true})); + assert!(init["agentCapabilities"]["sessionCapabilities"]["fork"].is_object()); + let new = result(&results, "new").as_ref().unwrap(); + let categories: Vec<&str> = + new["configOptions"].as_array().unwrap().iter().map(|o| o["category"].as_str().unwrap()).collect(); + // The model the user's codex config names; codex doesn't list it, so its efforts are unknown. + assert_eq!(categories, ["model", "mode"]); + let model = new["configOptions"][0]["currentValue"].as_str().unwrap(); + assert_eq!(model, "gpt-6-luna"); + assert!(new["configOptions"][0]["options"].as_array().unwrap().iter().any(|o| o["value"] == model)); + + match result(&results, "prompt") { + Err(RpcError::Server { code, message, .. }) => { + assert_eq!(*code, AUTH_REQUIRED); + assert!(message.to_lowercase().contains("auth"), "{message}"); + } + r => panic!("expected an auth failure, got {r:?}"), + } + let start = sent(&frames, "turn/start")[0]; + assert_eq!(start["params"]["model"], model); + assert!(start["params"]["approvalPolicy"].is_string() && start["params"]["sandboxPolicy"].is_object()); + assert_eq!(start["params"]["input"], json!([{"type": "text", "text": "Reply with the single word: hi"}])); + assert!(start.get("jsonrpc").is_some(), "codex accepts the jsonrpc field"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_stop_sent_right_after_the_prompt_interrupts_the_turn() { + let (results, _client, frames, _) = replay(Scenario::Interrupt).await; + let options = result(&results, "mode").as_ref().unwrap()["configOptions"].as_array().unwrap().clone(); + assert_eq!(options.iter().find(|o| o["id"] == MODE).unwrap()["currentValue"], "read-only"); + assert_eq!(result(&results, "prompt").as_ref().unwrap(), &json!({"stopReason": "cancelled"})); + let start = sent(&frames, "turn/start")[0]; + assert_eq!( + (start["params"]["approvalPolicy"].as_str(), &start["params"]["sandboxPolicy"]), + (Some("on-request"), &json!({"type": "readOnly"})) + ); + let interrupt = sent(&frames, "turn/interrupt"); + assert_eq!(interrupt.len(), 1); + assert!(interrupt[0]["params"]["turnId"].as_str().is_some_and(|t| !t.is_empty())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn resume_replays_history_then_forks_and_rolls_back() { + let (results, client, frames, meta) = replay(Scenario::Resume).await; + let thread = meta["thread"].as_str().unwrap(); + assert!(result(&results, "load").as_ref().unwrap()["configOptions"].is_array()); + assert_eq!(client.text(thread, "user_message_chunk"), meta["prompt"].as_str().unwrap()); + let fork = session_of(&results, "fork"); + assert_ne!(fork, thread); + assert_eq!(result(&results, "rollback").as_ref().unwrap(), &json!({})); + assert_eq!(sent(&frames, "thread/rollback")[0]["params"], json!({"threadId": thread, "numTurns": 1})); + assert_eq!(sent(&frames, "thread/unsubscribe")[0]["params"], json!({"threadId": thread})); +} + +#[tokio::test(flavor = "multi_thread")] +async fn steering_adds_to_the_running_turn() { + let (results, _client, frames, _) = replay(Scenario::Steer).await; + let steer = sent(&frames, "turn/steer"); + assert_eq!(steer.len(), 1); + assert_eq!(steer[0]["params"]["input"], text("Count to three instead.")); + assert_eq!(steer[0]["params"]["expectedTurnId"], sent(&frames, "turn/interrupt")[0]["params"]["turnId"]); + assert!(result(&results, "steer").is_ok(), "{:?}", result(&results, "steer")); + assert_eq!(result(&results, "prompt").as_ref().unwrap(), &json!({"stopReason": "cancelled"})); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_simple_turn_streams_reasoning_text_and_usage() { + let (results, client, _frames, _) = replay(Scenario::Simple).await; + let session = session_of(&results, "new"); + assert_eq!(result(&results, "prompt").as_ref().unwrap(), &json!({"stopReason": "end_turn"})); + assert_eq!(client.text(&session, "agent_thought_chunk"), "**Answering**\n\nShort reply."); + assert_eq!(client.text(&session, "agent_message_chunk"), "hi"); + let kinds: Vec = + client.of(&session).iter().map(|u| u["sessionUpdate"].as_str().unwrap().to_owned()).collect(); + assert!(kinds.contains(&"usage_update".to_owned())); + let info = client.of(&session).into_iter().find(|u| u["sessionUpdate"] == "session_info_update").unwrap(); + assert_eq!(info["title"], "Say hi"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn settings_apply_to_the_next_turn() { + let (results, client, frames, _) = replay(Scenario::MultiTurn).await; + let session = session_of(&results, "new"); + assert_eq!(result(&results, "first").as_ref().unwrap(), &json!({"stopReason": "end_turn"})); + assert_eq!(result(&results, "second").as_ref().unwrap(), &json!({"stopReason": "end_turn"})); + let options = &result(&results, "effort").as_ref().unwrap()["configOptions"]; + assert_eq!( + (options[0]["currentValue"].as_str(), options[1]["currentValue"].as_str()), + (Some("gpt-5.5"), Some("high")) + ); + let starts = sent(&frames, "turn/start"); + assert_eq!( + (starts[0]["params"]["model"].as_str(), starts[0]["params"]["effort"].as_str()), + (Some("gpt-5.6-luna"), Some("medium")) + ); + assert_eq!( + (starts[1]["params"]["model"].as_str(), starts[1]["params"]["effort"].as_str()), + (Some("gpt-5.5"), Some("high")) + ); + // Two messages of one turn are two paragraphs. + assert_eq!(client.text(&session, "agent_message_chunk"), "oneOn it.\n\ntwo"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn approvals_are_asked_and_answered() { + let (results, client, frames, _) = replay(Scenario::Approvals).await; + let session = session_of(&results, "new"); + assert_eq!(result(&results, "prompt").as_ref().unwrap(), &json!({"stopReason": "end_turn"})); + let asked = client.asked.lock().clone(); + assert_eq!(asked.len(), 2); + assert_eq!(asked[0]["toolCall"]["title"], "Run touch b.txt"); + let ids: Vec<&str> = + asked[0]["options"].as_array().unwrap().iter().map(|o| o["optionId"].as_str().unwrap()).collect(); + assert_eq!(ids, ["accept", "acceptWithExecpolicyAmendment", "decline", "cancel"]); + assert_eq!(asked[1]["toolCall"]["title"], "Create a.txt"); + assert_eq!(asked[1]["toolCall"]["content"][0]["newText"], "one\n"); + // Allowed once, then the cancelled one declined (the fixture checks the answers codex got). + let answers: Vec<&Value> = frames.iter().filter(|f| f.get("method").is_none()).map(|f| &f["result"]).collect(); + assert_eq!(answers, [&json!({"decision": "accept"}), &json!({"decision": "decline"})]); + + let updates = client.of(&session); + let tool = |id: &str| updates.iter().filter(|u| u["toolCallId"] == id).cloned().collect::>(); + let cmd = tool("call_cmd"); + assert_eq!(cmd[0]["sessionUpdate"], "tool_call"); + assert!(cmd.iter().any(|u| u.pointer("/content/0/content/text") == Some(&json!("touched\n")))); + assert_eq!(cmd.last().unwrap()["status"], "completed"); + let edit = tool("call_edit"); + assert_eq!(edit.last().unwrap()["status"], "failed"); + let plan = updates.iter().find(|u| u["sessionUpdate"] == "plan").unwrap(); + assert_eq!(plan["entries"][0]["status"], "completed"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn usage_limits_fail_with_their_reset_time() { + let (results, _client, _frames, _) = replay(Scenario::UsageLimit).await; + match result(&results, "prompt") { + Err(RpcError::Server { message, data, .. }) => { + assert!(message.contains("usage limit"), "{message}"); + assert_eq!(data, &Some(json!({"openidea": {"usageLimit": {"resetsAtMs": 1_793_200_000_000.0}}}))); + } + r => panic!("expected a usage limit, got {r:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn subagents_are_sessions_of_their_own() { + let (results, client, _frames, meta) = replay(Scenario::Subagent).await; + let session = session_of(&results, "new"); + let child = meta["child"].as_str().unwrap(); + assert_eq!(result(&results, "prompt").as_ref().unwrap(), &json!({"stopReason": "end_turn"})); + let parent = client.of(&session); + let cards: Vec<&Value> = parent.iter().filter(|u| u["sessionUpdate"] == "subagent_update").collect(); + assert_eq!(cards[0]["sessionId"], child); + assert_eq!(cards[0]["title"], "say_hello"); + assert_eq!(cards.last().unwrap()["state"], json!({"state": "idle", "stopReason": "end_turn"})); + let messages: Vec<&Value> = parent.iter().filter(|u| u["sessionUpdate"] == "session_message").collect(); + assert_eq!( + (messages[0]["recipientSessionId"].as_str(), messages[0]["content"][0]["text"].as_str()), + (Some(child), Some("Reply with: Hello")) + ); + assert_eq!( + (messages[1]["senderSessionId"].as_str(), messages[1]["content"][0]["text"].as_str()), + (Some(child), Some("Hello.")) + ); + assert_eq!(client.text(child, "agent_message_chunk"), "Hello."); + assert_eq!(client.text(&session, "agent_message_chunk"), "Hello."); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_client_going_away_stops_codex_and_codex_exiting_closes_the_client() { + let (ours, theirs) = tokio::io::duplex(1 << 16); + let codex_side = Arc::new(Mutex::new(None::)); + let acp = attach(Arc::new(Client::default()), |events| { + let (or, ow) = tokio::io::split(ours); + let peer = Peer::connect_framed(or, ow, events, Framing::Lines); + *codex_side.lock() = Some(peer.clone()); + Ok(peer) + }) + .unwrap(); + let codex = codex_side.lock().clone().unwrap(); + acp.kill(); + tokio::time::timeout(Duration::from_secs(5), codex.closed()).await.expect("codex is stopped"); + drop(theirs); + + let (ours, theirs) = tokio::io::duplex(1 << 16); + let acp = attach(Arc::new(Client::default()), |events| { + let (or, ow) = tokio::io::split(ours); + Ok(Peer::connect_framed(or, ow, events, Framing::Lines)) + }) + .unwrap(); + drop(theirs); + tokio::time::timeout(Duration::from_secs(5), acp.closed()).await.expect("the client sees codex exit"); +} + +// ---- Recording ------------------------------------------------------------------------- + +/// Records the scenarios codex can run here against the real `codex app-server`: +/// `cargo test -p ide-agent record_codex_fixtures -- --ignored --nocapture`. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "runs the real codex"] +async fn record_codex_fixtures() { + let only = std::env::var("CODEX_SCENARIO").ok(); + let version = std::process::Command::new("codex").arg("--version").output().unwrap(); + let version = String::from_utf8_lossy(&version.stdout).trim().to_owned(); + for scenario in [Scenario::AuthFailure, Scenario::Interrupt, Scenario::Steer, Scenario::Resume] { + if only.as_deref().is_some_and(|o| o != scenario.file()) { + continue; + } + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().canonicalize().unwrap(); + let cwd_s = cwd.to_string_lossy().into_owned(); + let mut meta = + json!({ "type": "meta", "scenario": scenario.file(), "source": format!("recorded from {version}") }); + let thread = if scenario == Scenario::Resume { + // A thread with one (stopped) turn, made by another codex process. + let prompt = "Count to five."; + let (client, acp, _log) = live(&cwd); + request(&acp, "initialize", json!({ "protocolVersion": 1 })).await.unwrap(); + let new = request(&acp, "session/new", json!({ "cwd": cwd_s, "mcpServers": [] })).await.unwrap(); + let id = new["sessionId"].as_str().unwrap().to_owned(); + let running = { + let (acp, p) = (acp.clone(), json!({ "sessionId": id, "prompt": text(prompt) })); + tokio::spawn(async move { request(&acp, "session/prompt", p).await }) + }; + tokio::time::sleep(Duration::from_secs(2)).await; + acp.notify("session/cancel", json!({ "sessionId": id })).unwrap(); + let _ = running.await; + drop(client); + acp.kill(); + meta["thread"] = json!(id); + meta["prompt"] = json!(prompt); + Some(id) + } else { + None + }; + let (client, acp, log) = live(&cwd); + let results = drive(scenario, &acp, &client, &cwd_s, thread.as_deref()).await; + println!( + "{}: {:?}", + scenario.file(), + results.iter().map(|(l, r)| (l, r.as_ref().map(|_| ()))).collect::>() + ); + tokio::time::sleep(Duration::from_millis(300)).await; + acp.kill(); + let home = std::env::var("HOME").unwrap_or_default(); + let mut out = serde_json::to_string(&meta).unwrap() + "\n"; + for line in log.lock().iter() { + out.push_str(&scrub(line, &[(&cwd_s, ""), (&home, "")])); + out.push('\n'); + } + std::fs::write(fixture_dir().join(format!("{}.jsonl", scenario.file())), out).unwrap(); + let fork = results.iter().find(|(l, _)| *l == "fork").and_then(|(_, r)| r.as_ref().ok()); + if let Some(fork) = fork.and_then(|f| f["sessionId"].as_str()) { + // A fork is loaded by a fresh process. + let (client, acp, _log) = live(&cwd); + request(&acp, "initialize", json!({ "protocolVersion": 1 })).await.unwrap(); + let loaded = + request(&acp, "session/load", json!({ "sessionId": fork, "cwd": cwd_s, "mcpServers": [] })).await; + println!("fork {fork} loaded from a fresh process: {:?}", loaded.map(|_| client.of(fork).len())); + acp.kill(); + } + } +} + +/// The shim over a real codex, with a tap writing each frame to the returned log. +fn live(cwd: &Path) -> (Arc, Peer, Arc>>) { + let log = Arc::new(Mutex::new(Vec::new())); + let client = Arc::new(Client::default()); + let cwd = cwd.to_path_buf(); + let tap = log.clone(); + let acp = attach(client.clone(), move |events| { + let mut child = tokio::process::Command::new("codex") + .arg("app-server") + .current_dir(&cwd) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true) + .spawn()?; + let (mut stdin, stdout) = (child.stdin.take().unwrap(), child.stdout.take().unwrap()); + let (ours, theirs) = tokio::io::duplex(1 << 20); + let (tr, mut tw) = tokio::io::split(theirs); + let out_log = tap.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(tr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + out_log.lock().push(format!("{{\"dir\":\"out\",\"frame\":{line}}}")); + if stdin.write_all(format!("{line}\n").as_bytes()).await.is_err() { + break; + } + } + let _ = child.kill().await; + }); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + tap.lock().push(format!("{{\"dir\":\"in\",\"frame\":{line}}}")); + if tw.write_all(format!("{line}\n").as_bytes()).await.is_err() { + break; + } + } + }); + let (or, ow) = tokio::io::split(ours); + Ok(Peer::connect_framed(or, ow, events, Framing::Lines)) + }) + .unwrap(); + (client, acp, log) +} + +/// A transcript line without local paths or machine identifiers. +fn scrub(line: &str, paths: &[(&str, &str)]) -> String { + let mut v: Value = serde_json::from_str(line).unwrap(); + if let Some(p) = v.pointer_mut("/frame/params").and_then(Value::as_object_mut) + && v_method(line) == Some("remoteControl/status/changed") + { + p.insert("serverName".into(), json!("")); + p.insert("installationId".into(), json!("")); + } + let mut s = v.to_string(); + for (from, to) in paths { + if !from.is_empty() { + s = s.replace(from, to); + } + } + s +} + +fn v_method(line: &str) -> Option<&str> { + let at = line.find("\"method\":\"")? + 10; + line[at..].split('"').next() +} diff --git a/crates/ide-agent/src/native/codex/translate.rs b/crates/ide-agent/src/native/codex/translate.rs new file mode 100644 index 000000000..365d4fe62 --- /dev/null +++ b/crates/ide-agent/src/native/codex/translate.rs @@ -0,0 +1,837 @@ +//! Pure translations between `codex app-server` (v2) messages and ACP. + +use serde_json::{Map, Value, json}; + +/// Tool output sent per update; longer output keeps its tail (the timeline keeps no more). +pub(super) const MAX_OUTPUT: usize = 64 * 1024; + +/// Approval and sandbox presets, offered as the session's mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Mode { + ReadOnly, + Ask, + Auto, + FullAccess, +} + +impl Mode { + pub const ALL: [Mode; 4] = [Mode::ReadOnly, Mode::Ask, Mode::Auto, Mode::FullAccess]; + + pub fn id(self) -> &'static str { + match self { + Mode::ReadOnly => "read-only", + Mode::Ask => "ask", + Mode::Auto => "auto", + Mode::FullAccess => "full-access", + } + } + + fn name(self) -> &'static str { + match self { + Mode::ReadOnly => "Read Only", + Mode::Ask => "Ask", + Mode::Auto => "Auto", + Mode::FullAccess => "Full Access", + } + } + + fn description(self) -> &'static str { + match self { + Mode::ReadOnly => "Reads files; asks before editing or running anything that writes", + Mode::Ask => "Edits in the workspace; asks before running commands it doesn't trust", + Mode::Auto => "Edits and runs commands in the workspace; asks to go beyond it", + Mode::FullAccess => "Edits and runs anything, with network access, without asking", + } + } + + pub fn parse(id: &str) -> Option { + Mode::ALL.into_iter().find(|m| m.id() == id) + } + + /// `approvalPolicy` (`AskForApproval`). + pub fn approval_policy(self) -> &'static str { + match self { + Mode::ReadOnly | Mode::Auto => "on-request", + Mode::Ask => "untrusted", + Mode::FullAccess => "never", + } + } + + /// `sandbox` of `thread/start`, `thread/resume` and `thread/fork` (`SandboxMode`). + pub fn sandbox_mode(self) -> &'static str { + match self { + Mode::ReadOnly => "read-only", + Mode::Ask | Mode::Auto => "workspace-write", + Mode::FullAccess => "danger-full-access", + } + } + + /// `sandboxPolicy` of `turn/start` (`SandboxPolicy`). + pub fn sandbox_policy(self) -> Value { + match self { + Mode::ReadOnly => json!({ "type": "readOnly" }), + Mode::Ask | Mode::Auto => json!({ "type": "workspaceWrite" }), + Mode::FullAccess => json!({ "type": "dangerFullAccess" }), + } + } + + /// The preset closest to what a thread runs with (`approvalPolicy`, `sandbox` policy). + pub fn of(approval: &Value, sandbox: &Value) -> Mode { + match sandbox.get("type").and_then(Value::as_str) { + Some("readOnly") => Mode::ReadOnly, + Some("dangerFullAccess" | "externalSandbox") => Mode::FullAccess, + _ if approval.as_str() == Some("untrusted") => Mode::Ask, + _ => Mode::Auto, + } + } +} + +/// A model `model/list` offers. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct ModelInfo { + pub id: String, + /// The slug sent as `model`. + pub model: String, + pub name: String, + pub description: Option, + /// `(effort, description)`. + pub efforts: Vec<(String, String)>, + pub default_effort: Option, + pub is_default: bool, + pub hidden: bool, +} + +pub(super) fn models_of(list: &Value) -> Vec { + let data = list.get("data").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + data.iter() + .filter_map(|m| { + let id = m.get("id")?.as_str()?.to_owned(); + Some(ModelInfo { + model: str_of(m, "model").unwrap_or_else(|| id.clone()), + name: str_of(m, "displayName").filter(|n| !n.is_empty()).unwrap_or_else(|| id.clone()), + description: str_of(m, "description").filter(|d| !d.is_empty()), + efforts: m + .get("supportedReasoningEfforts") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|e| { + Some(( + e.get("reasoningEffort")?.as_str()?.to_owned(), + str_of(e, "description").unwrap_or_default(), + )) + }) + .collect(), + default_effort: str_of(m, "defaultReasoningEffort"), + is_default: m.get("isDefault").and_then(Value::as_bool) == Some(true), + hidden: m.get("hidden").and_then(Value::as_bool) == Some(true), + id, + }) + }) + .collect() +} + +/// What a session runs with; sent with every turn. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Settings { + /// A model id from `model/list`, or the one the thread reported. + pub model: String, + pub effort: Option, + pub mode: Mode, +} + +impl Settings { + /// From a `thread/start`, `thread/resume` or `thread/fork` result. + pub fn of(result: &Value, models: &[ModelInfo]) -> Settings { + let reported = str_of(result, "model").unwrap_or_default(); + let model = models + .iter() + .find(|m| m.model == reported || m.id == reported) + .or_else(|| reported.is_empty().then(|| models.iter().find(|m| m.is_default)).flatten()) + .map_or(reported, |m| m.id.clone()); + let mut s = Settings { + model, + effort: str_of(result, "reasoningEffort"), + mode: Mode::of( + result.get("approvalPolicy").unwrap_or(&Value::Null), + result.get("sandbox").unwrap_or(&Value::Null), + ), + }; + s.fit_effort(models); + s + } + + /// The effort, made one the model supports (its default when it isn't). + pub fn fit_effort(&mut self, models: &[ModelInfo]) { + let Some(m) = models.iter().find(|m| m.id == self.model) else { return }; + if m.efforts.is_empty() { + self.effort = None; + } else if !self.effort.as_ref().is_some_and(|e| m.efforts.iter().any(|(x, _)| x == e)) { + self.effort = m.default_effort.clone().or_else(|| m.efforts.first().map(|(e, _)| e.clone())); + } + } + + /// The slug to send as `model`. + pub fn model_slug(&self, models: &[ModelInfo]) -> String { + models.iter().find(|m| m.id == self.model).map_or_else(|| self.model.clone(), |m| m.model.clone()) + } + + /// Applies `session/set_config_option`; `Err` names what is wrong. + pub fn set(&mut self, models: &[ModelInfo], option: &str, value: &str) -> Result<(), String> { + match option { + MODEL => { + if !models.iter().any(|m| m.id == value) && value != self.model { + return Err(format!("unknown model {value}")); + } + self.model = value.to_owned(); + self.fit_effort(models); + } + EFFORT => { + let m = models.iter().find(|m| m.id == self.model); + if !m.is_some_and(|m| m.efforts.iter().any(|(e, _)| e == value)) { + return Err(format!("{} has no reasoning effort {value}", self.model)); + } + self.effort = Some(value.to_owned()); + } + MODE => self.mode = Mode::parse(value).ok_or_else(|| format!("unknown mode {value}"))?, + _ => return Err(format!("unknown setting {option}")), + } + Ok(()) + } +} + +pub(super) const MODEL: &str = "model"; +pub(super) const EFFORT: &str = "reasoning_effort"; +pub(super) const MODE: &str = "mode"; + +/// ACP `configOptions`: model, reasoning effort (for models that have it) and mode. +pub(super) fn config_options(models: &[ModelInfo], s: &Settings) -> Value { + let mut model_values: Vec = models + .iter() + .filter(|m| !m.hidden || m.id == s.model) + .map(|m| select_value(&m.id, &m.name, m.description.as_deref())) + .collect(); + if !s.model.is_empty() && !models.iter().any(|m| m.id == s.model) { + model_values.push(select_value(&s.model, &s.model, None)); + } + let mut out = vec![json!({ + "id": MODEL, "name": "Model", "category": "model", "type": "select", + "currentValue": s.model, "options": model_values, + })]; + if let (Some(m), Some(effort)) = (models.iter().find(|m| m.id == s.model), &s.effort) + && !m.efforts.is_empty() + { + let values: Vec = m + .efforts + .iter() + .map(|(e, d)| select_value(e, &effort_name(e), Some(d).filter(|d| !d.is_empty()).map(String::as_str))) + .collect(); + out.push(json!({ + "id": EFFORT, "name": "Reasoning", "category": "thought_level", "type": "select", + "currentValue": effort, "options": values, + })); + } + out.push(json!({ + "id": MODE, "name": "Mode", "category": "mode", "type": "select", "currentValue": s.mode.id(), + "options": Mode::ALL.iter().map(|m| select_value(m.id(), m.name(), Some(m.description()))).collect::>(), + })); + Value::Array(out) +} + +fn select_value(value: &str, name: &str, description: Option<&str>) -> Value { + let mut v = json!({ "value": value, "name": name }); + if let Some(d) = description { + v["description"] = json!(d); + } + v +} + +fn effort_name(effort: &str) -> String { + match effort { + "xhigh" => "Extra high".into(), + e => { + let mut c = e.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() + } + } +} + +/// ACP prompt content blocks as codex `UserInput`s. Embedded files become text. +pub(super) fn user_input(prompt: &Value) -> Vec { + let blocks = prompt.as_array().map(Vec::as_slice).unwrap_or_default(); + blocks + .iter() + .filter_map(|b| match b.get("type").and_then(Value::as_str)? { + "text" => Some(json!({ "type": "text", "text": b.get("text")?.as_str()? })), + "image" => { + let url = match (b.get("data").and_then(Value::as_str), b.get("uri").and_then(Value::as_str)) { + (Some(data), _) => { + format!( + "data:{};base64,{data}", + b.get("mimeType").and_then(Value::as_str).unwrap_or("image/png") + ) + } + (None, Some(uri)) => uri.to_owned(), + _ => return None, + }; + Some(json!({ "type": "image", "url": url })) + } + "resource" => { + let r = b.get("resource")?; + let path = uri_path(r.get("uri").and_then(Value::as_str).unwrap_or_default()); + let text = r.get("text").and_then(Value::as_str)?; + Some(json!({ "type": "text", "text": format!("\n{text}\n") })) + } + "resource_link" => Some(json!({ "type": "text", "text": uri_path(b.get("uri")?.as_str()?) })), + _ => None, + }) + .collect() +} + +/// The path of a `file://` URI (percent-decoded); other URIs as they are. +fn uri_path(uri: &str) -> String { + let Some(rest) = uri.strip_prefix("file://") else { return uri.to_owned() }; + let bytes = rest.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && let Some(b) = rest.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok()) + { + out.push(b); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// A `session/update` notification's `update` for content. +pub(super) fn chunk(kind: &str, text: &str) -> Value { + json!({ "sessionUpdate": kind, "content": { "type": "text", "text": text } }) +} + +/// ACP tool status of a codex item `status`; items without one are running until they complete. +fn tool_status(item: &Value, done: bool) -> &'static str { + match item.get("status").and_then(Value::as_str) { + Some("completed") => "completed", + Some("failed" | "declined") => "failed", + Some(_) => "in_progress", + None if done => "completed", + None => "in_progress", + } +} + +/// The `tool_call` (or with `done`, the `tool_call_update`) for a tool-like item; `None` +/// for messages, reasoning and subagent bookkeeping. `output` is the command output +/// streamed so far, used when the item carries none. +pub(super) fn tool_call(item: &Value, done: bool, cwd: Option<&str>, output: Option<&str>) -> Option { + let id = item.get("id")?.as_str()?; + let ty = item.get("type")?.as_str()?; + let mut call = Map::new(); + let (title, kind) = match ty { + "commandExecution" => { + let (title, kind, locations) = command_title(item, cwd); + call.insert("rawInput".into(), json!({ "command": item.get("command"), "cwd": item.get("cwd") })); + if !locations.is_empty() { + call.insert("locations".into(), Value::Array(locations)); + } + let text = item.get("aggregatedOutput").and_then(Value::as_str).or(output); + if let Some(text) = text.filter(|t| !t.is_empty()) { + call.insert("content".into(), json!([text_content(&tail(text))])); + } + if done && let Some(code) = item.get("exitCode").filter(|c| !c.is_null()) { + call.insert("rawOutput".into(), json!({ "exitCode": code })); + } + (title, kind) + } + "fileChange" => { + let changes = item.get("changes").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let diffs: Vec = changes.iter().filter_map(file_diff).collect(); + let locations: Vec = + diffs.iter().filter_map(|d| d.get("path")).map(|p| json!({ "path": p })).collect(); + call.insert("locations".into(), Value::Array(locations)); + if !diffs.is_empty() { + call.insert("content".into(), Value::Array(diffs)); + } + file_change_title(changes, cwd) + } + "mcpToolCall" => { + call.insert("rawInput".into(), item.get("arguments").cloned().unwrap_or(Value::Null)); + let mut content: Vec = item + .pointer("/result/content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|c| c.get("type").is_some()) + .map(|c| json!({ "type": "content", "content": c })) + .collect(); + if let Some(message) = item.pointer("/error/message").and_then(Value::as_str) { + content.push(text_content(message)); + } + if !content.is_empty() { + call.insert("content".into(), Value::Array(content)); + } + let server = item.get("server").and_then(Value::as_str).unwrap_or_default(); + let tool = item.get("tool").and_then(Value::as_str).unwrap_or_default(); + (format!("{server}: {tool}"), "other") + } + "dynamicToolCall" => { + call.insert("rawInput".into(), item.get("arguments").cloned().unwrap_or(Value::Null)); + let content: Vec = item + .get("contentItems") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|c| c.get("text").and_then(Value::as_str)) + .map(text_content) + .collect(); + if !content.is_empty() { + call.insert("content".into(), Value::Array(content)); + } + (str_of(item, "tool").unwrap_or_default(), "other") + } + "webSearch" => (web_search_title(item), "fetch"), + "imageView" => { + let path = item.get("path").and_then(Value::as_str).unwrap_or_default(); + call.insert("locations".into(), json!([{ "path": path }])); + (format!("View {}", display_path(path, cwd)), "read") + } + "collabAgentToolCall" => { + let title = match item.get("tool").and_then(Value::as_str) { + Some("spawnAgent") => return None, + Some("sendInput") => "Message an agent", + Some("resumeAgent") => "Resume an agent", + Some("wait") => "Wait for agents", + Some("closeAgent") => "Close an agent", + _ => "Coordinate agents", + }; + (title.to_owned(), "other") + } + "contextCompaction" => ("Compact the conversation".to_owned(), "other"), + "sleep" => ("Wait".to_owned(), "other"), + "imageGeneration" => ("Generate an image".to_owned(), "other"), + _ => return None, + }; + call.insert("sessionUpdate".into(), json!(if done { "tool_call_update" } else { "tool_call" })); + call.insert("toolCallId".into(), json!(id)); + call.insert("title".into(), json!(title)); + call.insert("kind".into(), json!(kind)); + call.insert("status".into(), json!(tool_status(item, done))); + Some(Value::Object(call)) +} + +/// The output of a running command, as a `tool_call_update`. +pub(super) fn command_output(id: &str, output: &str) -> Value { + json!({ "sessionUpdate": "tool_call_update", "toolCallId": id, "content": [text_content(&tail(output))] }) +} + +fn text_content(text: &str) -> Value { + json!({ "type": "content", "content": { "type": "text", "text": text } }) +} + +/// Title, ACP kind and locations of a command: what it does when codex recognised it +/// (reading, listing, searching), else the command itself. +fn command_title(item: &Value, cwd: Option<&str>) -> (String, &'static str, Vec) { + let actions = item.get("commandActions").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let ty = |a: &Value| a.get("type").and_then(Value::as_str).unwrap_or("unknown").to_owned(); + let all = |t: &str| !actions.is_empty() && actions.iter().all(|a| ty(a) == t); + if all("read") { + let names: Vec = actions.iter().filter_map(|a| str_of(a, "name")).collect(); + let locations = actions.iter().filter_map(|a| a.get("path")).map(|p| json!({ "path": p })).collect(); + return (format!("Read {}", names.join(", ")), "read", locations); + } + if all("listFiles") { + let path = + actions[0].get("path").and_then(Value::as_str).map_or_else(|| ".".to_owned(), |p| display_path(p, cwd)); + return (format!("List {path}"), "search", Vec::new()); + } + if all("search") && actions.len() == 1 { + let a = &actions[0]; + let query = a.get("query").and_then(Value::as_str); + let path = a.get("path").and_then(Value::as_str).map(|p| display_path(p, cwd)); + let title = match (query, path) { + (Some(q), Some(p)) => format!("Search {q} in {p}"), + (Some(q), None) => format!("Search {q}"), + (None, Some(p)) => format!("Search in {p}"), + (None, None) => "Search".to_owned(), + }; + return (title, "search", Vec::new()); + } + // The command as the model wrote it, without the shell wrapper codex adds. + let command = match actions { + [one] => one.get("command").and_then(Value::as_str), + _ => None, + }; + let command = command.or_else(|| item.get("command").and_then(Value::as_str)).unwrap_or_default(); + (command.to_owned(), "execute", Vec::new()) +} + +fn file_change_title(changes: &[Value], cwd: Option<&str>) -> (String, &'static str) { + let kind_of = |c: &Value| c.pointer("/kind/type").and_then(Value::as_str).unwrap_or("update").to_owned(); + let moved = |c: &Value| c.pointer("/kind/move_path").and_then(Value::as_str).map(str::to_owned); + match changes { + [one] => { + let path = display_path(one.get("path").and_then(Value::as_str).unwrap_or_default(), cwd); + match (kind_of(one).as_str(), moved(one)) { + ("add", _) => (format!("Create {path}"), "edit"), + ("delete", _) => (format!("Delete {path}"), "delete"), + (_, Some(to)) => (format!("Move {path} to {}", display_path(&to, cwd)), "move"), + _ => (format!("Edit {path}"), "edit"), + } + } + _ if !changes.is_empty() && changes.iter().all(|c| kind_of(c) == "delete") => { + (format!("Delete {} files", changes.len()), "delete") + } + _ => (format!("Edit {} files", changes.len()), "edit"), + } +} + +/// An ACP `diff` content item for a `FileUpdateChange`: a new file's `diff` is its +/// content, a deleted one's its old content, an update's a unified diff. +fn file_diff(change: &Value) -> Option { + let path = change.get("path")?.as_str()?; + let diff = change.get("diff").and_then(Value::as_str).unwrap_or_default(); + let (path, old, new) = match change.pointer("/kind/type").and_then(Value::as_str) { + Some("add") => (path, String::new(), diff.to_owned()), + Some("delete") => (path, diff.to_owned(), String::new()), + _ => { + let (old, new) = diff_sides(diff); + (change.pointer("/kind/move_path").and_then(Value::as_str).unwrap_or(path), old, new) + } + }; + Some(json!({ "type": "diff", "path": path, "oldText": old, "newText": new })) +} + +/// The old and new text a unified diff's hunks cover (context included). +pub(super) fn diff_sides(diff: &str) -> (String, String) { + let (mut old, mut new) = (String::new(), String::new()); + let mut in_hunk = false; + for line in diff.split_inclusive('\n') { + if line.starts_with("@@") { + in_hunk = true; + continue; + } + if !in_hunk { + continue; + } + match line.as_bytes().first() { + Some(b' ') => { + old.push_str(&line[1..]); + new.push_str(&line[1..]); + } + Some(b'-') => old.push_str(&line[1..]), + Some(b'+') => new.push_str(&line[1..]), + // `\ No newline at end of file`, or the next file's header. + Some(b'\\') => {} + Some(b'\n') => { + old.push('\n'); + new.push('\n'); + } + _ => in_hunk = false, + } + } + (old, new) +} + +fn web_search_title(item: &Value) -> String { + let action = item.get("action"); + let field = |k: &str| action.and_then(|a| a.get(k)).and_then(Value::as_str).filter(|s| !s.is_empty()); + let query = item.get("query").and_then(Value::as_str).filter(|q| !q.is_empty()); + match action.and_then(|a| a.get("type")).and_then(Value::as_str) { + Some("openPage") => field("url").map_or_else(|| "Open a page".to_owned(), |u| format!("Open {u}")), + Some("findInPage") => match field("pattern") { + Some(p) => format!("Find {p}"), + None => "Find in page".to_owned(), + }, + _ => field("query").or(query).map_or_else(|| "Search the web".to_owned(), |q| format!("Search the web: {q}")), + } +} + +/// `path` relative to `cwd` when it is inside it. +fn display_path(path: &str, cwd: Option<&str>) -> String { + cwd.and_then(|c| path.strip_prefix(c.trim_end_matches('/'))) + .and_then(|rest| rest.strip_prefix('/')) + .filter(|r| !r.is_empty()) + .unwrap_or(path) + .to_owned() +} + +/// `turn/plan/updated` as an ACP `plan`. +pub(super) fn plan(params: &Value) -> Value { + let entries: Vec = params + .get("plan") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|s| { + let status = match s.get("status").and_then(Value::as_str) { + Some("completed") => "completed", + Some("inProgress") => "in_progress", + _ => "pending", + }; + json!({ "content": s.get("step").and_then(Value::as_str).unwrap_or_default(), "priority": "medium", "status": status }) + }) + .collect(); + json!({ "sessionUpdate": "plan", "entries": entries }) +} + +/// `thread/tokenUsage/updated` as an ACP `usage_update`: what the last request put in +/// the context window, out of its size. +pub(super) fn usage(params: &Value) -> Option { + let usage = params.get("tokenUsage")?; + let used = usage.pointer("/last/totalTokens")?.as_u64()?; + let size = usage.get("modelContextWindow")?.as_u64().filter(|s| *s > 0)?; + Some(json!({ "sessionUpdate": "usage_update", "used": used, "size": size })) +} + +/// How a failed turn is reported. +#[derive(Debug, PartialEq)] +pub(super) enum Failure { + /// The account's usage limit; when it resets (ms since the epoch), when known. + UsageLimit(Option), + /// Signed out or refused. + Auth, + /// The model declined. + Refusal, + Other, +} + +/// What kind of failure a `TurnError` is; `rate_limits` is the last +/// `account/rateLimits/updated` snapshot. +pub(super) fn classify(error: &Value, rate_limits: Option<&Value>) -> Failure { + let info = error.get("codexErrorInfo").unwrap_or(&Value::Null); + let http_status = + info.as_object().and_then(|o| o.values().next()).and_then(|v| v.get("httpStatusCode")).and_then(Value::as_u64); + let message = error.get("message").and_then(Value::as_str).unwrap_or_default().to_lowercase(); + match info.as_str() { + Some("usageLimitExceeded") => return Failure::UsageLimit(resets_at(rate_limits)), + Some("unauthorized") => return Failure::Auth, + Some("cyberPolicy") => return Failure::Refusal, + _ => {} + } + if http_status == Some(429) && info.get("responseTooManyFailedAttempts").is_some() { + return Failure::UsageLimit(resets_at(rate_limits)); + } + if http_status == Some(401) || message.contains("401 unauthorized") { + return Failure::Auth; + } + Failure::Other +} + +/// When the exhausted rate-limit window resets, in ms. +fn resets_at(rate_limits: Option<&Value>) -> Option { + let limits = rate_limits?; + let windows = ["primary", "secondary"].map(|w| limits.get(w).filter(|w| !w.is_null())); + let exhausted = windows + .iter() + .flatten() + .filter(|w| w.get("usedPercent").and_then(Value::as_f64).is_some_and(|p| p >= 100.0)) + .filter_map(|w| w.get("resetsAt").and_then(Value::as_f64)) + .fold(None, |acc: Option, t| Some(acc.map_or(t, |a| a.max(t)))); + exhausted.map(|s| s * 1000.0) +} + +/// The text a failed turn reports: codex's message, with its details. +pub(super) fn error_message(error: &Value) -> String { + let message = error.get("message").and_then(Value::as_str).unwrap_or("Codex failed").trim(); + match error.get("additionalDetails").and_then(Value::as_str).map(str::trim).filter(|d| !d.is_empty()) { + Some(details) if !message.contains(details) => format!("{message}\n{details}"), + _ => message.to_owned(), + } +} + +/// One choice of an approval: what ACP shows and what codex gets back. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Choice { + pub option: Value, + pub answer: Value, +} + +/// The choices of a command or file-change approval: what codex offers +/// (`availableDecisions`), else the usual four; a way to decline is always there. +pub(super) fn decisions(params: &Value, command: bool) -> Vec { + let offered: Vec = match params.get("availableDecisions").and_then(Value::as_array) { + Some(list) if !list.is_empty() => list.clone(), + _ => ["accept", "acceptForSession", "decline", "cancel"].map(|d| json!(d)).to_vec(), + }; + let mut list = offered; + if !list.iter().any(|d| d == "decline") { + let at = list.iter().position(|d| d == "cancel").unwrap_or(list.len()); + list.insert(at, json!("decline")); + } + let mut out: Vec = Vec::new(); + for decision in list { + let (id, name, kind) = match &decision { + Value::String(s) => match s.as_str() { + "accept" => { + ("accept".to_owned(), if command { "Allow once" } else { "Allow" }.to_owned(), "allow_once") + } + "acceptForSession" => { + ("acceptForSession".to_owned(), "Allow for this session".to_owned(), "allow_always") + } + "decline" => ("decline".to_owned(), "Decline".to_owned(), "reject_once"), + "cancel" => ("cancel".to_owned(), "Decline and stop".to_owned(), "reject_once"), + other => (other.to_owned(), other.to_owned(), "allow_once"), + }, + Value::Object(o) => { + let Some((key, v)) = o.iter().next() else { continue }; + match key.as_str() { + "acceptWithExecpolicyAmendment" => { + let prefix: Vec<&str> = v + .get("execpolicy_amendment") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + let shown = shell_unwrapped(&prefix); + (key.clone(), format!("Always allow `{shown}`"), "allow_always") + } + "applyNetworkPolicyAmendment" => { + let a = v.get("network_policy_amendment").unwrap_or(&Value::Null); + let host = a.get("host").and_then(Value::as_str).unwrap_or("this host"); + match a.get("action").and_then(Value::as_str) { + Some("deny") => { + (format!("{key}:deny:{host}"), format!("Always block {host}"), "reject_always") + } + _ => (format!("{key}:allow:{host}"), format!("Always allow {host}"), "allow_always"), + } + } + other => (other.to_owned(), other.to_owned(), "allow_once"), + } + } + _ => continue, + }; + if out.iter().any(|c| c.option["optionId"] == json!(id)) { + continue; + } + out.push(Choice { + option: json!({ "optionId": id, "name": name, "kind": kind }), + answer: json!({ "decision": decision }), + }); + } + out +} + +/// A command prefix as typed: the script of a ` -lc