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