diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 485d17272..de4111c59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,10 +97,11 @@ is derived from it. **Process** is how to build, check and submit work. ### Reporting bugs and asking for features Open an [issue](https://github.com/Tryanks/tcode/issues). For a bug, include -your OS, which agent (Claude Code / Codex / which ACP agent), what you did, what -happened and what you expected. A screenshot beats a paragraph for anything -visual. If you are unsure whether something is a bug or intended, open an issue -anyway — the answer is worth writing down either way. +your OS, which agent (Claude Code, Codex, pi, OpenCode, Cursor, Grok, or which +ACP agent), what you did, what happened and what you expected. A screenshot +beats a paragraph for anything visual. If you are unsure whether something is a +bug or intended, open an issue anyway — the answer is worth writing down either +way. ### Building @@ -121,8 +122,8 @@ Platform prerequisites: `libwayland-dev`, `libxcb*`, `libssl-dev`, `libasound2-dev`, `libfontconfig-dev`). The embedded preview browser is compiled out on Linux. -Provider CLIs are resolved from `PATH` (`claude`, `codex`, `pi`, `opencode`) and -can be overridden in **Settings → Providers**. +Provider CLIs are resolved from `PATH` (`claude`, `codex`, `pi`, `opencode`, +`cursor-agent`, `grok`) and can be overridden in **Settings → Providers**. ### Before you open a pull request @@ -275,7 +276,8 @@ crates/ios, crates/android, crates/web `tcode_ui::run_shell` crates/platform/* GPUI platform backends for iOS and Android crates/agent provider clients (no GPUI) — claude.rs, codex.rs, pi.rs, - opencode.rs, acp.rs + opencode.rs, cursor.rs, grok.rs, acp.rs; the ACP + session they share is acp_session.rs crates/term terminal implementation (PTY, host-side emulator) crates/mcp-host shared authenticated loopback host for in-process MCP servers crates/preview-mcp MCP server exposing the preview browser to the agent diff --git a/README.md b/README.md index 685acfd17..84c71c142 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **A native GUI for the terminal coding agents you already use.** -Claude Code, Codex, pi, OpenCode, and any agent that speaks ACP — one window, +Claude Code, Codex, pi, OpenCode, Cursor, Grok, and any agent that speaks ACP — one window, on your desktop, your phone, or in a browser. [Download](https://github.com/Tryanks/tcode/releases) · @@ -78,6 +78,8 @@ and a live plan/task panel. | [Codex](https://developers.openai.com/codex/cli) | `codex` on your `PATH` | | [pi](https://github.com/earendil-works/pi) | `pi` on your `PATH` | | [OpenCode](https://opencode.ai) | `opencode` on your `PATH` | +| [Cursor](https://cursor.com/cli) | `cursor-agent` on your `PATH` | +| [Grok](https://x.ai) | `grok` on your `PATH` | **Everything else, over [ACP](https://agentclientprotocol.com).** Tcode ships a marketplace backed by the official ACP registry. @@ -179,7 +181,7 @@ also include a `SHA256SUMS.txt` file. | Browser | Embedded in the headless release; open the HTTP link it prints (HTTPS only through your own tunnel). No separate signed app package | **2. Have an agent installed.** Tcode drives the CLIs, it doesn't bundle them. -Make sure `claude`, `codex`, `pi` or `opencode` is on your `PATH` — or install an ACP agent from +Make sure `claude`, `codex`, `pi`, `opencode`, `cursor-agent` or `grok` is on your `PATH` — or install an ACP agent from the marketplace once Tcode is running. **3. Add a project and start a thread.** Point Tcode at a directory, type, send. diff --git a/assets/icons/cursor.svg b/assets/icons/cursor.svg new file mode 100644 index 000000000..9f1876a6e --- /dev/null +++ b/assets/icons/cursor.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/grok.svg b/assets/icons/grok.svg new file mode 100644 index 000000000..9fb5e2ea6 --- /dev/null +++ b/assets/icons/grok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/agent/examples/probe.rs b/crates/agent/examples/probe.rs index 308ab284e..2c18052d3 100644 --- a/crates/agent/examples/probe.rs +++ b/crates/agent/examples/probe.rs @@ -1,17 +1,23 @@ //! Headless end-to-end probe for provider clients. //! -//! Catalog mode: `probe --list-models `. +//! Catalog mode: `probe --list-models [--binary ]`. //! Turn mode: `probe [cwd] [approval] [acp-command args…] [flags]`. -//! Flags are `--mode plan`, `--effort `, `--interrupt-after `, -//! `--steer `, and `--image `. Only one of the last three may be used. +//! Flags are `--binary `, `--model `, `--mode plan`, `--effort `, +//! `--resume `, `--fork`, `--leave-questions` (user-input requests +//! stay unanswered), `--mcp ` (an HTTP MCP server registered +//! as tcode registers its own), `--linger ` (stay open until no turn has +//! run for that long, to see turns the agent starts itself), `--follow-up +//! ` (send another turn that long after the first completes, +//! running turn or not), `--interrupt-after `, `--steer `, and +//! `--image `. Only one of the last three may be used. use std::path::PathBuf; -use std::time::Duration; +use std::time::{Duration, Instant}; use agent::{ AcpAgent, AcpLaunch, AgentEvent, ApprovalDecision, ApprovalMode, Attachment, InteractionMode, - ItemContent, OptionSelection, ProviderKind, SessionCommand, SessionOptions, TurnOptions, - TurnStatus, list_models, start_session, + ItemContent, McpRegistration, OptionSelection, ProviderKind, ResumeCursor, SessionCommand, + SessionOptions, TurnOptions, TurnStatus, list_models, start_session, }; use base64::Engine as _; @@ -29,10 +35,12 @@ enum ProbeMode { fn usage() -> ! { eprintln!( - "usage: probe [cwd] \ - [supervised|auto_edits|full_access] [acp-command args…] [flags]" + "usage: probe [cwd] \ + [supervised|read_only|auto_edits|full_access] [acp-command args…] [flags]" + ); + eprintln!( + " probe --list-models [--binary ]" ); - eprintln!(" probe --list-models "); std::process::exit(2); } @@ -42,6 +50,8 @@ fn parse_provider(arg: Option<&str>) -> ProviderKind { Some("claude") => ProviderKind::ClaudeCode, Some("pi") => ProviderKind::Pi, Some("opencode") => ProviderKind::OpenCode, + Some("cursor") => ProviderKind::Cursor, + Some("grok") => ProviderKind::Grok, Some("acp") => ProviderKind::Acp, _ => usage(), } @@ -91,8 +101,13 @@ fn main() { let args: Vec = std::env::args().skip(1).collect(); if args.first().map(String::as_str) == Some("--list-models") { let provider = parse_provider(args.get(1).map(String::as_str)); + let binary = match args.get(2).map(String::as_str) { + Some("--binary") => Some(args.get(3).map(PathBuf::from).unwrap_or_else(|| usage())), + Some(_) => usage(), + None => None, + }; let exit_code = smol::block_on(async move { - match list_models(provider, None, Default::default(), Default::default()).await { + match list_models(provider, binary, Default::default(), Default::default()).await { Ok(models) => { println!("{}", serde_json::to_string_pretty(&models).unwrap()); 0 @@ -107,7 +122,14 @@ fn main() { } let mut interaction_mode = InteractionMode::Build; + let mut binary = None; + let mut model = None; let mut effort = None; + let mut resume = None; + let mut fork = false; + let mut leave_questions = false; + let mut mcp_servers = Vec::new(); + let mut continuation = Continuation::default(); let mut probe_mode = ProbeMode::Standard; let mut positional = Vec::new(); let mut args = args.into_iter(); @@ -124,7 +146,38 @@ fn main() { None => usage(), }; } + "--binary" => binary = Some(args.next().map(PathBuf::from).unwrap_or_else(|| usage())), + "--model" => model = Some(args.next().unwrap_or_else(|| usage())), "--effort" => effort = Some(args.next().unwrap_or_else(|| usage())), + "--resume" => { + let cursor = args.next().unwrap_or_else(|| usage()); + resume = Some(ResumeCursor(serde_json::from_str(&cursor).unwrap_or_else( + |error| { + eprintln!("--resume takes the JSON of a resume cursor: {error}"); + std::process::exit(2); + }, + ))); + } + "--fork" => fork = true, + "--leave-questions" => leave_questions = true, + "--linger" => { + let seconds = args.next().and_then(|value| value.parse().ok()); + continuation.linger = Some(Duration::from_secs(seconds.unwrap_or_else(|| usage()))); + } + "--follow-up" => { + let seconds = args.next().and_then(|value| value.parse().ok()); + let seconds = seconds.unwrap_or_else(|| usage()); + let prompt = args.next().unwrap_or_else(|| usage()); + continuation.follow_up = Some((Duration::from_secs(seconds), prompt)); + } + "--mcp" => { + let mut next = || args.next().unwrap_or_else(|| usage()); + mcp_servers.push(McpRegistration { + name: next(), + url: next(), + bearer_token: next(), + }); + } "--interrupt-after" => { let seconds = args .next() @@ -162,15 +215,20 @@ fn main() { }); let mut remaining: Vec = positional.collect(); let approval_mode = match remaining.first().map(String::as_str) { - Some("supervised" | "auto_edits" | "full_access") => match remaining.remove(0).as_str() { - "supervised" => ApprovalMode::Supervised, - "auto_edits" => ApprovalMode::AutoAcceptEdits, - _ => ApprovalMode::FullAccess, - }, + Some("supervised" | "read_only" | "auto_edits" | "full_access") => { + match remaining.remove(0).as_str() { + "supervised" => ApprovalMode::Supervised, + "read_only" => ApprovalMode::ReadOnly, + "auto_edits" => ApprovalMode::AutoAcceptEdits, + _ => ApprovalMode::FullAccess, + } + } None => ApprovalMode::Supervised, Some(_) if provider == ProviderKind::Acp => ApprovalMode::Supervised, Some(other) => { - eprintln!("unknown approval mode {other:?}; use supervised|auto_edits|full_access"); + eprintln!( + "unknown approval mode {other:?}; use supervised|read_only|auto_edits|full_access" + ); std::process::exit(2); } }; @@ -210,10 +268,31 @@ fn main() { effort, probe_mode, acp, + mcp_servers, + continuation, + Resumption { + resume, + fork, + leave_questions, + }, + binary, + model, )); std::process::exit(exit_code); } +struct Resumption { + resume: Option, + fork: bool, + leave_questions: bool, +} + +#[derive(Default)] +struct Continuation { + linger: Option, + follow_up: Option<(Duration, String)>, +} + #[allow(clippy::too_many_arguments)] async fn run_probe( provider: ProviderKind, @@ -224,28 +303,38 @@ async fn run_probe( effort: Option, probe_mode: ProbeMode, acp: Option, + mcp_servers: Vec, + continuation: Continuation, + resumption: Resumption, + binary_path: Option, + model: Option, ) -> i32 { + // Grok's effort is its wire config option, persisted under its option id. + let effort_id = match provider { + ProviderKind::Grok => "acp:cfg:reasoning_effort", + _ => "reasoningEffort", + }; let option_selections = effort .iter() .map(|value| OptionSelection { - id: "reasoningEffort".into(), + id: effort_id.into(), value: serde_json::Value::String(value.clone()), }) .collect(); - let model = match (provider, effort.is_some()) { + let model = model.or_else(|| match (provider, effort.is_some()) { (ProviderKind::ClaudeCode, true) => Some("claude-opus-4-8".to_string()), _ => None, - }; + }); let opts = SessionOptions { cwd, model, - resume: None, - fork: false, - binary_path: None, + resume: resumption.resume, + fork: resumption.fork, + binary_path, approval_mode, option_selections, interaction_mode, - mcp_servers: Vec::new(), + mcp_servers, launch_env: Default::default(), extra_args: Vec::new(), acp, @@ -318,18 +407,46 @@ async fn run_probe( let mut turns_completed = 0; let mut first_status = None; let mut steer_accepted = false; + let lingering = continuation + .linger + .or(continuation.follow_up.as_ref().map(|_| PHANTOM_TURN_GRACE)); + let mut follow_up = continuation.follow_up; + let mut open_until = None; + let mut running = false; + let mut closing = false; loop { - let event = if matches!(probe_mode, ProbeMode::Steer(_)) && turns_completed > 0 { - smol::future::or(handle.events.recv(), async { - smol::Timer::after(PHANTOM_TURN_GRACE).await; - Err(smol::channel::RecvError) - }) - .await - .ok() - } else { - handle.events.recv().await.ok() + let quiet = match lingering { + Some(linger) if turns_completed > 0 && !running && !closing => Some(linger), + _ if matches!(probe_mode, ProbeMode::Steer(_)) && turns_completed > 0 => { + Some(PHANTOM_TURN_GRACE) + } + _ => None, + }; + let event = match quiet { + Some(quiet) => { + let event = smol::future::or(handle.events.recv(), async { + smol::Timer::after(quiet).await; + Err(smol::channel::RecvError) + }) + .await + .ok(); + if event.is_none() { + if open_until.is_none_or(|until| Instant::now() >= until) { + closing = true; + handle.commands.send(SessionCommand::Shutdown).await.ok(); + } + continue; + } + event + } + None => handle.events.recv().await.ok(), }; let Some(event) = event else { break }; + match &event { + AgentEvent::TurnStarted { .. } => running = true, + AgentEvent::TurnCompleted { .. } => running = false, + _ => {} + } if provider == ProviderKind::Acp { match &event { @@ -360,6 +477,9 @@ async fn run_probe( .await .ok(); } + AgentEvent::UserInputRequested { questions, .. } if resumption.leave_questions => { + eprintln!("probe: leaving {} question(s) unanswered", questions.len()); + } AgentEvent::UserInputRequested { request_id, questions, @@ -440,7 +560,25 @@ async fn run_probe( "probe: TurnCompleted {turn_id} status={status:?} (#{turns_completed})" ); } - if !matches!(probe_mode, ProbeMode::Steer(_)) { + if let Some((delay, text)) = follow_up.take() { + open_until = Some(Instant::now() + delay + Duration::from_secs(1)); + let commands = handle.commands.clone(); + smol::spawn(async move { + smol::Timer::after(delay).await; + eprintln!("probe: FOLLOW-UP -> {text:?}"); + commands + .send(SessionCommand::SendTurn { + delivery_id: 1, + text, + options: None, + attachments: Vec::new(), + }) + .await + .ok(); + }) + .detach(); + } + if !matches!(probe_mode, ProbeMode::Steer(_)) && lingering.is_none() { handle.commands.send(SessionCommand::Shutdown).await.ok(); } } diff --git a/crates/agent/src/acp.rs b/crates/agent/src/acp.rs index 8016cc189..e828b1a77 100644 --- a/crates/agent/src/acp.rs +++ b/crates/agent/src/acp.rs @@ -1,97 +1,32 @@ //! Agent Client Protocol provider: any agent from the ACP registry. //! //! Registry-managed agents share this adapter; native providers retain their -//! richer protocol-specific clients. -//! -//! Shape: one child process per session, JSON-RPC over its stdio, driven by an -//! `agent_client_protocol::Client` connection builder. The whole connection -//! lives on a dedicated thread running a `LocalExecutor`; the canonical -//! [`AgentEvent`] stream and the [`SessionCommand`] channel are the only things -//! that cross the thread boundary — exactly like `claude.rs` / `codex.rs`. +//! richer protocol-specific clients. The protocol machinery lives in +//! [`crate::acp_session`]; this module is the registry's dialect: launching a +//! registry recipe and the generic policies for agents tcode knows nothing +//! more about. -use std::collections::{HashMap, VecDeque}; -use std::fmt::Write as _; -use std::io; -use std::path::{Path, PathBuf}; -use std::pin::Pin; -use std::rc::Rc; -use std::sync::{ - Arc, Mutex, MutexGuard, - atomic::{AtomicBool, Ordering}, -}; -use std::task::{Context, Poll}; +use std::path::PathBuf; use std::time::Duration; -use agent_client_protocol::{self as sdk, schema::ProtocolVersion, schema::v1 as acp}; +use agent_client_protocol::schema::v1 as acp; use serde_json::{Value, json}; -use smol::channel::{Receiver, Sender}; use smol::future; -use smol::io::{AsyncRead, AsyncReadExt as _, AsyncWrite}; -use smol::prelude::*; +use crate::acp_session::{ + self, Dialect, Established, Launch, Session, Setup, State, TurnEnd, describe, is_auth_required, + prompt_status, +}; use crate::{ - AcpAgent, AcpLaunch, AgentError, AgentEvent, ApprovalDecision, ApprovalKind, ApprovalMode, - ApprovalOption, ApprovalOptionKind, ApprovalRequest, Attachment, DeltaKind, FileChange, - FileChangeKind, InteractionMode, ItemContent, ItemStatus, McpRegistration, OptionDescriptor, - OptionSelection, PlanStep, PlanStepStatus, ProviderCommand, ProviderCommandKind, ProviderKind, - ResumeCursor, SelectOption, SessionCommand, SessionHandle, SessionOptions, ThreadItem, - TokenUsage, TurnStatus, + AcpAgent, AcpLaunch, AgentError, AgentEvent, ApprovalMode, Attachment, InteractionMode, + ProviderKind, ResumeCursor, SessionHandle, SessionOptions, TokenUsage, }; -/// Option-descriptor ids. The composer renders an ACP agent's own -/// modes/models/config options through the existing traits picker, so each one -/// needs a stable id that routes back to the right ACP method. -const MODE_OPTION_ID: &str = "acp:mode"; -const MODEL_OPTION_ID: &str = "acp:model"; -const CONFIG_OPTION_PREFIX: &str = "acp:cfg:"; - -/// Cap on captured terminal output when the agent sets none (1 MiB). -const DEFAULT_TERMINAL_OUTPUT_LIMIT: u64 = 1 << 20; - -/// How long we wait for an agent to answer `initialize` before declaring it -/// broken. Generous: an `npm exec` recipe may have to fetch the package first. -const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(120); - /// How long we let an agent's `authenticate` run before giving up and telling /// the user to sign in with the agent's own CLI (Gemini's is a browser OAuth /// flow that otherwise blocks session startup for five minutes). const AUTH_TIMEOUT: Duration = Duration::from_secs(20); -type AcpConnection = sdk::ConnectionTo; - -trait MutexExt { - fn lock_recover(&self) -> MutexGuard<'_, T>; -} - -impl MutexExt for Mutex { - fn lock_recover(&self) -> MutexGuard<'_, T> { - self.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) - } -} - -macro_rules! request_handler { - ($client:expr, $request:ty, $method:ident) => {{ - let client = $client.clone(); - async move |args: $request, responder, connection| { - let client = client.clone(); - connection - .spawn(async move { responder.respond_with_result(client.$method(args).await) })?; - Ok(()) - } - }}; - ($client:expr, $request:ty, $method:ident, connection) => {{ - let client = $client.clone(); - async move |args: $request, responder, connection| { - let client = client.clone(); - let task_connection = connection.clone(); - connection.spawn(async move { - responder.respond_with_result(client.$method(args, &task_connection).await) - })?; - Ok(()) - } - }}; -} - /// Start (or resume) a session with an ACP agent. pub async fn start(opts: SessionOptions) -> Result { if opts.fork { @@ -99,723 +34,208 @@ pub async fn start(opts: SessionOptions) -> Result { "session fork is not supported for this provider".into(), )); } - let (commands_tx, commands_rx) = smol::channel::unbounded(); - let (events_tx, events_rx) = smol::channel::unbounded(); - let (ready_tx, ready_rx) = smol::channel::bounded(1); - - // Keep each ACP connection and all of its callbacks on one dedicated - // executor thread. The 1.2 SDK requires Send handlers internally, but the - // adapter still exposes only channels across this boundary. - std::thread::Builder::new() - .name("acp-session".into()) - .spawn(move || { - let executor = Rc::new(smol::LocalExecutor::new()); - let task = run_actor(executor.clone(), opts, commands_rx, events_tx, ready_tx); - smol::block_on(executor.run(task)); - }) - .map_err(|err| { - AgentError::Spawn(format!("could not start the ACP session thread: {err}")) - })?; - - ready_rx.recv().await.map_err(|_| { - AgentError::Protocol("ACP actor exited before reporting startup status".into()) - })??; - - Ok(SessionHandle { - provider: ProviderKind::Acp, - commands: commands_tx, - events: events_rx, - }) -} - -/// The resolved command line for a launch recipe. -/// -/// `Npx` becomes `npm exec --yes -- ` (the registry's own -/// contract, and what zed runs); `Binary` / `Custom` run as given. -fn launch_command(launch: &AcpLaunch) -> Result<(PathBuf, Vec), AgentError> { - match launch { - AcpLaunch::Npx { package, args, .. } => { - let npm = crate::resolve_binary(None, "npm")?; - let mut argv = vec![ - "exec".to_string(), - "--yes".to_string(), - "--".to_string(), - package.clone(), - ]; - argv.extend(args.iter().cloned()); - Ok((npm, argv)) - } - AcpLaunch::Binary { command, args, .. } => Ok((command.clone(), args.clone())), - AcpLaunch::Custom { command, args, .. } => { - let binary = crate::resolve_binary(None, command)?; - Ok((binary, args.clone())) - } - } -} - -fn spawn_agent( - agent: &AcpAgent, - opts: &SessionOptions, -) -> Result { - let (program, mut args) = launch_command(&agent.launch)?; - args.extend(opts.extra_args.iter().cloned()); - - let mut cmd = crate::process::async_command(&program); - cmd.args(&args) - .current_dir(&opts.cwd) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - // Recipe env first, the user's configured env last (the user always wins). - let recipe_env = match &agent.launch { - AcpLaunch::Npx { env, .. } - | AcpLaunch::Binary { env, .. } - | AcpLaunch::Custom { env, .. } => env, + let Some(agent) = opts.acp.clone() else { + return Err(AgentError::Protocol( + "no ACP agent selected for this session".into(), + )); }; - for (key, value) in recipe_env { - cmd.env(key, value); - } - for (key, value) in opts.launch_env.pairs(ProviderKind::Acp) { - cmd.env(key, value); - } - log::debug!( - "spawning ACP agent {}: {} {:?}", - agent.id, - program.display(), - args - ); - cmd.spawn().map_err(|err| { - AgentError::Spawn(format!( - "could not launch ACP agent `{}` ({}): {err}", - agent.name, - program.display() - )) - }) -} - -/// Reports an agent closing (or breaking) stdout while still handing the bytes -/// to the SDK transport. The 1.2 `ByteStreams` component treats a clean EOF as -/// a completed input stream, so observing it here preserves tcode's immediate -/// `SessionClosed` behavior instead of leaving the command loop waiting. -struct ObservedReader { - inner: smol::process::ChildStdout, - done: Sender, -} - -impl ObservedReader { - fn new(inner: smol::process::ChildStdout, done: Sender) -> Self { - Self { inner, done } - } -} - -impl AsyncRead for ObservedReader { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut [u8], - ) -> Poll> { - match Pin::new(&mut self.inner).poll_read(cx, buf) { - Poll::Ready(Ok(0)) => { - let _ = self - .done - .try_send("the ACP agent closed its stdio".to_string()); - Poll::Ready(Ok(0)) - } - Poll::Ready(Err(err)) => { - let _ = self.done.try_send(format!("ACP transport error: {err}")); - Poll::Ready(Err(err)) - } - other => other, - } - } + acp_session::start(ProviderKind::Acp, Registry { agent }, opts).await } -/// Reports a turn only after the SDK has written its complete JSON-RPC prompt -/// line to the child. `ConnectionTo::send_request` merely queues internally and -/// does not expose a synchronous enqueue failure, so it is not a delivery -/// boundary on its own. -struct ObservedWriter { - inner: W, - line: Vec, - pending_deliveries: Arc>>, - events: Sender, +/// An agent from the registry (or a custom command), driven by the generic +/// ACP policies. +struct Registry { + agent: AcpAgent, } -impl ObservedWriter { - fn new( - inner: W, - pending_deliveries: Arc>>, - events: Sender, - ) -> Self { - Self { - inner, - line: Vec::new(), - pending_deliveries, - events, - } - } - - fn finish_line(&mut self) { - let is_prompt = serde_json::from_slice::(&self.line) - .ok() - .is_some_and(|message| { - message.get("method").and_then(Value::as_str) == Some("session/prompt") - }); - self.line.clear(); - if !is_prompt { - return; - } - if let Some(delivery_id) = self.pending_deliveries.lock_recover().pop_front() { - let _ = self - .events - .try_send(AgentEvent::TurnAccepted { delivery_id }); - } +impl Dialect for Registry { + fn name(&self) -> &str { + &self.agent.name } -} -impl AsyncWrite for ObservedWriter { - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - let written = match Pin::new(&mut self.inner).poll_write(cx, buf) { - Poll::Ready(Ok(written)) => written, - other => return other, + fn launch(&self, opts: &SessionOptions) -> Result { + let (program, mut args) = launch_command(&self.agent.launch)?; + args.extend(opts.extra_args.iter().cloned()); + let env = match &self.agent.launch { + AcpLaunch::Npx { env, .. } + | AcpLaunch::Binary { env, .. } + | AcpLaunch::Custom { env, .. } => env.clone(), }; - for byte in &buf[..written] { - if *byte == b'\n' { - self.finish_line(); - } else { - self.line.push(*byte); - } - } - Poll::Ready(Ok(written)) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_close(cx) + Ok(Launch { + program, + args, + env, + client_meta: None, + }) } -} - -async fn run_actor( - executor: Rc>, - opts: SessionOptions, - commands: Receiver, - events: Sender, - ready: Sender>, -) { - let Some(agent) = opts.acp.clone() else { - let _ = ready - .send(Err(AgentError::Protocol( - "no ACP agent selected for this session".into(), - ))) - .await; - return; - }; - - let mut child = match spawn_agent(&agent, &opts) { - Ok(child) => child, - Err(err) => { - let _ = ready.send(Err(err)).await; - return; - } - }; - let Some(stdin) = child.stdin.take() else { - let _ = ready - .send(Err(AgentError::Spawn(format!( - "ACP agent `{}` started without piped stdin", - agent.name - )))) - .await; - return; - }; - let Some(stdout) = child.stdout.take() else { - let _ = ready - .send(Err(AgentError::Spawn(format!( - "ACP agent `{}` started without piped stdout", - agent.name - )))) - .await; - return; - }; - let Some(stderr) = child.stderr.take() else { - let _ = ready - .send(Err(AgentError::Spawn(format!( - "ACP agent `{}` started without piped stderr", - agent.name - )))) - .await; - return; - }; - // The agent's stderr is its log channel: keep the tail so a startup failure - // can be reported in the agent's own words. - let stderr_tail = crate::process::StderrTail::default(); - executor - .spawn({ - let tail = stderr_tail.clone(); - let id = agent.id.clone(); - async move { - let mut lines = smol::io::BufReader::new(stderr).lines(); - while let Some(Ok(line)) = lines.next().await { - log::debug!("acp[{id}] stderr: {line}"); - tail.push(line); + async fn establish(&self, setup: &Setup<'_>) -> Result { + let agent = &self.agent; + let opts = setup.opts; + let mcp_servers = setup.mcp_servers().await; + let resumed = opts + .resume + .as_ref() + .and_then(|cursor| cursor.0.get("acp_session_id")) + .and_then(Value::as_str) + .map(str::to_string) + .filter(|_| setup.init.agent_capabilities.load_session); + + let mut loaded_session = None; + if let Some(session_id) = resumed { + // `session/load` replays the whole conversation as `session/update` + // notifications. Our JSONL log is the authoritative history and the + // UI has already folded it, so the replay is swallowed (see + // `session_update`); we only want the session live again. + let session_id = acp::SessionId::new(session_id); + let loaded = setup + .load( + acp::LoadSessionRequest::new(session_id.clone(), opts.cwd.clone()) + .mcp_servers(mcp_servers.clone()), + ) + .await; + match loaded { + Ok(loaded) => { + loaded_session = Some((session_id, loaded.modes, loaded.config_options)); } - } - }) - .detach(); - - let state = Arc::new(Mutex::new(State::new(opts.cwd.clone()))); - let client = AcpClient { - events: events.clone(), - state: state.clone(), - }; - let (io_done_tx, io_done) = smol::channel::bounded::(1); - let pending_deliveries = Arc::new(Mutex::new(VecDeque::new())); - let transport = sdk::ByteStreams::new( - ObservedWriter::new(stdin, pending_deliveries.clone(), events.clone()), - ObservedReader::new(stdout, io_done_tx), - ); - let session_started = Arc::new(AtomicBool::new(false)); - let connection_result = sdk::Client - .builder() - .name(format!("tcode-acp-{}", agent.id)) - .on_receive_notification( - { - let client = client.clone(); - async move |args: acp::SessionNotification, _connection| { - client.session_notification(args).await + Err(err) => { + log::warn!( + "acp[{}]: session/load failed ({}); starting a fresh session", + agent.id, + describe(&err) + ); + setup + .warn(format!( + "{} could not resume the previous conversation; starting a new one", + agent.name + )) + .await; } - }, - sdk::on_receive_notification!(), - ) - .on_receive_request( - request_handler!(client, acp::RequestPermissionRequest, request_permission), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!(client, acp::ReadTextFileRequest, read_text_file), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!(client, acp::WriteTextFileRequest, write_text_file), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!( - client, - acp::CreateTerminalRequest, - create_terminal, - connection - ), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!(client, acp::TerminalOutputRequest, terminal_output), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!( - client, - acp::WaitForTerminalExitRequest, - wait_for_terminal_exit - ), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!(client, acp::KillTerminalRequest, kill_terminal), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!(client, acp::ReleaseTerminalRequest, release_terminal), - sdk::on_receive_request!(), - ) - .connect_with(transport, { - let session_started = session_started.clone(); - let stderr_tail = stderr_tail.clone(); - let actor_events = events.clone(); - let actor_ready = ready.clone(); - async move |connection| { - connected_actor( - &executor, - connection, - &agent, - &opts, - &commands, - &actor_events, - &actor_ready, - &state, - &stderr_tail, - &io_done, - &session_started, - &pending_deliveries, - ) - .await } - }) - .await; - - // The connection closure owns the protocol shutdown; this is the hard - // process boundary for both graceful shutdowns and broken transports. - let _ = child.kill(); - - if !session_started.load(Ordering::Acquire) { - if let Err(err) = connection_result { - let message = format!("ACP transport error: {}", describe(&err)); - let message = stderr_tail.append_to(message, "\n"); - let _ = ready.send(Err(AgentError::Protocol(message))).await; - } - return; - } - - let close_reason = match connection_result { - Ok(reason) => reason, - Err(err) => Some(format!("ACP transport error: {}", describe(&err))), - }; - let close_reason = close_reason.map(|reason| stderr_tail.append_to(reason, "\nstderr:\n")); - let _ = events - .send(AgentEvent::SessionClosed { - reason: close_reason, - }) - .await; -} - -#[allow(clippy::too_many_arguments)] -async fn connected_actor( - executor: &Rc>, - connection: AcpConnection, - agent: &AcpAgent, - opts: &SessionOptions, - commands: &Receiver, - events: &Sender, - ready: &Sender>, - state: &Arc>, - stderr_tail: &crate::process::StderrTail, - io_done: &Receiver, - session_started: &AtomicBool, - pending_deliveries: &Arc>>, -) -> Result, acp::Error> { - enum Startup { - Handshake(Result), - Io(String), - } - - let startup = future::or( - async { Startup::Handshake(handshake(&connection, agent, opts, state, events).await) }, - async { - Startup::Io( - io_done - .recv() - .await - .unwrap_or_else(|_| "the ACP agent closed its stdio".to_string()), - ) - }, - ) - .await; - let session = match startup { - Startup::Handshake(Ok(session)) => session, - Startup::Handshake(Err(err)) => { - let err = match err { - AgentError::Protocol(message) => { - AgentError::Protocol(stderr_tail.append_to(message, "\n")) - } - other => other, - }; - let _ = ready.send(Err(err)).await; - return Ok(None); } - Startup::Io(reason) => { - let message = stderr_tail.append_to(reason, "\n"); - let _ = ready.send(Err(AgentError::Protocol(message))).await; - return Ok(None); + let (session_id, modes, config_options) = match loaded_session { + Some(session) => session, + None => { + let new = new_session(agent, setup, &mcp_servers).await?; + (new.session_id, new.modes, new.config_options) + } + }; + setup.adopt(modes.as_ref(), config_options.as_deref()); + + let wants_plan = opts.approval_mode == ApprovalMode::ReadOnly + || opts.interaction_mode == InteractionMode::Plan; + if wants_plan || setup.in_plan_mode() { + setup + .apply_interaction_mode( + &session_id, + if wants_plan { + InteractionMode::Plan + } else { + InteractionMode::Build + }, + ) + .await; } - }; - - let _ = events - .send(AgentEvent::SessionStarted { - provider_session_id: session.session_id.0.to_string(), + Ok(Established { resume: ResumeCursor(json!({ - "acp_session_id": session.session_id.0.to_string(), + "acp_session_id": session_id.0.to_string(), "acp_agent_id": agent.id, })), - model: session.model.clone(), + session_id, }) - .await; - emit_provider_options(state, events, false).await; - if ready.send(Ok(())).await.is_err() { - return Ok(None); } - session_started.store(true, Ordering::Release); - - let (turn_tx, turn_done) = smol::channel::unbounded::(); - let mut turn_id: Option = None; - let mut turn_seq: u64 = 0; - let close_reason = loop { - enum Input { - Command(Result), - Turn(TurnOutcome), - Io(String), + fn session_update( + &self, + state: &mut State, + notification: acp::SessionNotification, + ) -> Vec { + // While `session/load` replays the conversation we already have on disk, + // swallow everything: our JSONL log is the source of truth and the + // timeline was folded from it before the process even started. + if state.loading() { + return Vec::new(); } - let input = future::or( - future::or(async { Input::Command(commands.recv().await) }, async { - match turn_done.recv().await { - Ok(outcome) => Input::Turn(outcome), - // This loop holds the sender, so the channel cannot close. - Err(_) => future::pending().await, - } - }), - async { - match io_done.recv().await { - Ok(reason) => Input::Io(reason), - Err(_) => future::pending().await, - } - }, - ) - .await; + state.apply_update(notification.update) + } - match input { - Input::Command(Ok(SessionCommand::Shutdown)) | Input::Command(Err(_)) => break None, - Input::Command(Ok(command)) => { - handle_command( - command, - executor, - &connection, - &session, - state, - events, - &turn_tx, - &mut turn_id, - &mut turn_seq, - pending_deliveries, - ) - .await; - } - Input::Turn(outcome) => { - let id = turn_id - .take() - .expect("turn outcome requires an active turn"); - finish_turn(state, events, &id, outcome).await; - } - Input::Io(reason) => break Some(reason), + fn turn_end( + &self, + state: &mut State, + result: Result, + ) -> TurnEnd { + let (status, message) = prompt_status(&result); + let usage = result + .ok() + .and_then(|response| response.usage) + .map(|usage| TokenUsage { + freshness: crate::ContextFreshness::Current, + total_processed_tokens: Some(usage.total_tokens), + input_tokens: Some(usage.input_tokens), + cached_input_tokens: usage.cached_read_tokens, + output_tokens: Some(usage.output_tokens), + ..TokenUsage::default() + }) + // Fall back to the live context-window figure from `usage_update`. + .or_else(|| state.usage()); + TurnEnd { + status, + message, + usage, } - }; - - // Only a live transport can acknowledge a graceful close. A dead stdout - // has already ended the session and must not leave us awaiting a response. - if close_reason.is_none() && session.can_close { - let _ = connection - .send_request(acp::CloseSessionRequest::new(session.session_id.clone())) - .block_task() - .await; } - Ok(close_reason) -} - -/// What `session/new` (or `session/load`) settled on. -struct Session { - session_id: acp::SessionId, - model: Option, - can_close: bool, -} - -struct TurnOutcome { - result: Result, -} - -async fn handshake( - connection: &AcpConnection, - agent: &AcpAgent, - opts: &SessionOptions, - state: &Arc>, - events: &Sender, -) -> Result { - // On a leash: an agent that starts but never answers `initialize` (cline - // 3.0.39 does exactly this) would otherwise hang session startup forever, - // with the UI stuck on "Starting…". - let init = future::or( - async { - Some( - connection - .send_request( - acp::InitializeRequest::new(ProtocolVersion::LATEST) - .client_capabilities( - acp::ClientCapabilities::new() - .fs(acp::FileSystemCapabilities::new() - .read_text_file(true) - .write_text_file(true)) - .terminal(true), - ) - .client_info( - acp::Implementation::new("tcode", env!("CARGO_PKG_VERSION")) - .title("Tcode"), - ), - ) - .block_task() - .await, - ) - }, - async { - smol::Timer::after(INITIALIZE_TIMEOUT).await; - None - }, - ) - .await; - let init = match init { - Some(Ok(init)) => init, - Some(Err(err)) => { - return Err(AgentError::Protocol(format!( - "`{}` failed to initialize: {}", - agent.name, - describe(&err) - ))); - } - None => { - return Err(AgentError::Protocol(format!( - "`{}` did not answer `initialize` within {}s — it may not speak ACP over stdio (check its launch arguments)", - agent.name, - INITIALIZE_TIMEOUT.as_secs() - ))); - } - }; + async fn steer( + &self, + _session: &Session, + _request_id: String, + _text: String, + _attachments: Vec, + ) { + } - let caps = init.agent_capabilities.clone(); - // Capability gate: tcode's MCP servers are loopback streamable-HTTP - // endpoints, so they may only be offered to agents that speak MCP over HTTP. - let registrations: Vec<_> = opts.mcp_servers.iter().collect(); - let mcp_servers = mcp_servers(®istrations, &caps); - if !registrations.is_empty() && mcp_servers.is_empty() { - log::info!( - "acp[{}]: no mcpCapabilities.http; Tcode MCP servers are not registered", - agent.id - ); - let _ = events - .send(AgentEvent::Warning { message: format!( - "{} does not support HTTP MCP servers; Tcode MCP tools are unavailable in this session", - agent.name - ) }) + async fn set_approval_mode(&self, session: &Session, _mode: ApprovalMode) { + session + .emit(AgentEvent::Warning { + message: + "ACP agents own their permission policy; use the agent's own mode selector" + .into(), + }) .await; } +} - let resumed = opts - .resume - .as_ref() - .and_then(|cursor| cursor.0.get("acp_session_id")) - .and_then(Value::as_str) - .map(str::to_string) - .filter(|_| caps.load_session); - - let mut loaded_session = None; - if let Some(session_id) = resumed { - // `session/load` replays the whole conversation as `session/update` - // notifications. Our JSONL log is the authoritative history and the - // UI has already folded it, so the replay is swallowed (see - // `State::replaying`); we only want the session live again. - state.lock_recover().replaying = true; - let session_id = acp::SessionId::new(session_id); - let loaded = connection - .send_request( - acp::LoadSessionRequest::new(session_id.clone(), opts.cwd.clone()) - .mcp_servers(mcp_servers.clone()), - ) - .block_task() - .await; - state.lock_recover().replaying = false; - match loaded { - Ok(loaded) => { - loaded_session = Some((session_id, loaded.modes, loaded.config_options)); - } - Err(err) => { - log::warn!( - "acp[{}]: session/load failed ({}); starting a fresh session", - agent.id, - describe(&err) - ); - let _ = events - .send(AgentEvent::Warning { - message: format!( - "{} could not resume the previous conversation; starting a new one", - agent.name - ), - }) - .await; - } +/// The resolved command line for a launch recipe. +/// +/// `Npx` becomes `npm exec --yes -- ` (the registry's own +/// contract); `Binary` / `Custom` run as given. +fn launch_command(launch: &AcpLaunch) -> Result<(PathBuf, Vec), AgentError> { + match launch { + AcpLaunch::Npx { package, args, .. } => { + let npm = crate::resolve_binary(None, "npm")?; + let mut argv = vec![ + "exec".to_string(), + "--yes".to_string(), + "--".to_string(), + package.clone(), + ]; + argv.extend(args.iter().cloned()); + Ok((npm, argv)) } - } - let (session_id, modes, config_options) = match loaded_session { - Some(session) => session, - None => { - let new = new_session(connection, agent, opts, &mcp_servers, &init).await?; - (new.session_id, new.modes, new.config_options) + AcpLaunch::Binary { command, args, .. } => Ok((command.clone(), args.clone())), + AcpLaunch::Custom { command, args, .. } => { + let binary = crate::resolve_binary(None, command)?; + Ok((binary, args.clone())) } - }; - - let wants_plan = opts.approval_mode == ApprovalMode::ReadOnly - || opts.interaction_mode == InteractionMode::Plan; - { - let mut state = state.lock_recover(); - state.ingest_modes(modes.as_ref()); - state.options.ingest(None, config_options.as_deref()); - } - let should_apply_mode = wants_plan - || state - .lock_recover() - .modes - .as_ref() - .is_some_and(|modes| acp_plan_mode(modes).as_ref() == Some(&modes.current_mode_id)); - if should_apply_mode { - apply_interaction_mode( - if wants_plan { - InteractionMode::Plan - } else { - InteractionMode::Build - }, - state, - events, - connection, - &session_id, - ) - .await; } - let model = state.lock_recover().options.current_model(); - - Ok(Session { - session_id, - model, - can_close: caps.session_capabilities.close.is_some(), - }) -} - -fn acp_plan_mode(modes: &acp::SessionModeState) -> Option { - modes - .available_modes - .iter() - .find(|mode| mode.id.0.eq_ignore_ascii_case("plan")) - .map(|mode| mode.id.clone()) -} - -fn first_non_plan_mode(modes: &acp::SessionModeState) -> Option { - let plan = acp_plan_mode(modes); - modes - .available_modes - .iter() - .find(|mode| Some(&mode.id) != plan.as_ref()) - .map(|mode| mode.id.clone()) } async fn new_session( - connection: &AcpConnection, agent: &AcpAgent, - opts: &SessionOptions, + setup: &Setup<'_>, mcp_servers: &[acp::McpServer], - init: &acp::InitializeResponse, ) -> Result { + let (connection, opts, init) = (setup.connection, setup.opts, setup.init); let request = || acp::NewSessionRequest::new(opts.cwd.clone()).mcp_servers(mcp_servers.to_vec()); match connection.send_request(request()).block_task().await { @@ -950,2339 +370,3 @@ fn auth_hint(agent: &AcpAgent, init: &acp::InitializeResponse) -> String { agent.name, agent.name ) } - -fn is_auth_required(err: &acp::Error) -> bool { - i32::from(err.code) == -32000 -} - -fn describe(err: &acp::Error) -> String { - match &err.data { - Some(data) => format!("{} ({data})", err.message), - None => err.message.clone(), - } -} - -/// The `mcpServers` array for `session/new`, gated on `mcpCapabilities.http`. -fn mcp_servers( - registrations: &[&McpRegistration], - caps: &acp::AgentCapabilities, -) -> Vec { - if !caps.mcp_capabilities.http { - return Vec::new(); - } - registrations - .iter() - .map(|mcp| { - acp::McpServer::Http( - acp::McpServerHttp::new(mcp.name.clone(), mcp.url.clone()).headers(vec![ - acp::HttpHeader::new("Authorization", format!("Bearer {}", mcp.bearer_token)), - ]), - ) - }) - .collect() -} - -#[allow(clippy::too_many_arguments)] -async fn handle_command( - command: SessionCommand, - executor: &Rc>, - connection: &AcpConnection, - session: &Session, - state: &Arc>, - events: &Sender, - turn_tx: &Sender, - turn_id: &mut Option, - turn_seq: &mut u64, - pending_deliveries: &Arc>>, -) { - match command { - SessionCommand::Steer { .. } => {} - SessionCommand::SendTurn { - delivery_id, - text, - attachments, - .. - } => { - *turn_seq += 1; - let id = format!("turn-{turn_seq}"); - *turn_id = Some(id.clone()); - state.lock_recover().turn = Some(id.clone()); - - let request = acp::PromptRequest::new( - session.session_id.clone(), - prompt_blocks(&text, &attachments), - ); - // The observed stdio writer consumes this id when the complete - // `session/prompt` JSON-RPC line reaches the child. - pending_deliveries.lock_recover().push_back(delivery_id); - let request = connection.send_request(request); - let _ = events - .send(AgentEvent::TurnStarted { - turn_id: id.clone(), - }) - .await; - let turn_tx = turn_tx.clone(); - executor - .spawn(async move { - let result = request.block_task().await; - let _ = turn_tx.send(TurnOutcome { result }).await; - }) - .detach(); - } - SessionCommand::Interrupt => { - if turn_id.is_none() { - return; - } - // Every in-flight permission request must be answered with - // `cancelled` first: the protocol requires it, and the agent will - // not settle the turn otherwise. - let pending: Vec> = state - .lock_recover() - .approvals - .drain() - .map(|(_, (responder, _))| responder) - .collect(); - for responder in pending { - let _ = responder - .send(acp::RequestPermissionOutcome::Cancelled) - .await; - } - let _ = connection - .send_notification(acp::CancelNotification::new(session.session_id.clone())); - // The agent still owes us the `session/prompt` response carrying - // `stopReason: cancelled`; the turn completes when it lands. - } - SessionCommand::RespondApproval { - request_id, - decision, - } => { - let approval = state.lock_recover().approvals.remove(&request_id); - let Some((responder, options)) = approval else { - log::warn!("acp: no pending approval {request_id}"); - return; - }; - let outcome = match approval_outcome(&decision, &options) { - Some(outcome) => outcome, - None => { - let _ = events - .send(AgentEvent::Warning { - message: - "the agent offered no matching permission option; cancelling instead" - .into(), - }) - .await; - acp::RequestPermissionOutcome::Cancelled - } - }; - let _ = responder.send(outcome).await; - let _ = events - .send(AgentEvent::ApprovalResolved { - request_id, - decision, - }) - .await; - } - SessionCommand::SetOption { id, value } => { - let Some(origin) = state.lock_recover().options.origin(&id) else { - log::warn!("acp: unknown option id `{id}`"); - return; - }; - match set_option(connection, session, &origin, &value).await { - Ok(config_options) => { - { - let mut state = state.lock_recover(); - if let Some(options) = config_options.as_deref() { - state.options.ingest(None, Some(options)); - } - if origin == OptionOrigin::Mode { - if let Some(mode) = value.as_str() { - state.select_mode(acp::SessionModeId::new(mode)); - } - } else { - state.options.select(&id, value); - } - } - emit_provider_options(state, events, false).await; - } - Err(err) => { - let _ = events - .send(AgentEvent::Warning { - message: format!("could not apply `{id}`: {}", describe(&err)), - }) - .await; - } - } - } - SessionCommand::RespondUserInput { .. } => { - // ACP has no structured-question equivalent (`session/elicitation` - // is not in the pinned schema), so we never raise one. - } - SessionCommand::SetApprovalMode(_) => { - let _ = events - .send(AgentEvent::Warning { - message: - "ACP agents own their permission policy; use the agent's own mode selector" - .into(), - }) - .await; - } - SessionCommand::SetInteractionMode(mode) => { - apply_interaction_mode(mode, state, events, connection, &session.session_id).await; - } - SessionCommand::Rewind { - checkpoint_id, - mode, - } => { - let _ = events - .send(AgentEvent::RewindFailed { - checkpoint_id, - mode, - error: "this ACP agent does not advertise a native rewind operation".into(), - }) - .await; - } - SessionCommand::Shutdown => unreachable!("handled by the caller"), - } -} - -async fn set_option( - connection: &AcpConnection, - session: &Session, - origin: &OptionOrigin, - value: &Value, -) -> Result>, acp::Error> { - match origin { - OptionOrigin::Mode => { - let Some(mode) = value.as_str() else { - return Err(acp::Error::invalid_params()); - }; - connection - .send_request(acp::SetSessionModeRequest::new( - session.session_id.clone(), - acp::SessionModeId::new(mode), - )) - .block_task() - .await?; - Ok(None) - } - OptionOrigin::Config(config_id) => { - let value = match value { - Value::Bool(value) => acp::SessionConfigOptionValue::boolean(*value), - Value::String(value) => acp::SessionConfigOptionValue::value_id( - acp::SessionConfigValueId::new(value.as_str()), - ), - _ => return Err(acp::Error::invalid_params()), - }; - let response = connection - .send_request(acp::SetSessionConfigOptionRequest::new( - session.session_id.clone(), - config_id.clone(), - value, - )) - .block_task() - .await?; - Ok(Some(response.config_options)) - } - } -} - -async fn apply_interaction_mode( - mode: InteractionMode, - state: &Arc>, - events: &Sender, - connection: &AcpConnection, - session_id: &acp::SessionId, -) { - let target = interaction_mode_target(mode, &state.lock_recover()); - let Some(target) = target else { - let _ = events.send(missing_mode_warning(mode)).await; - // Republish the actual selection so the runtime rolls back its - // optimistic Build/Plan toggle instead of leaving a lying chip. - emit_provider_options(state, events, true).await; - return; - }; - - if state - .lock_recover() - .modes - .as_ref() - .is_some_and(|modes| modes.current_mode_id == target) - { - return; - } - - match connection - .send_request(acp::SetSessionModeRequest::new( - session_id.clone(), - target.clone(), - )) - .block_task() - .await - { - Ok(_) => { - state.lock_recover().select_mode(target); - emit_provider_options(state, events, false).await; - } - Err(err) => { - let _ = events - .send(AgentEvent::Warning { - message: format!("could not switch this agent's mode: {}", describe(&err)), - }) - .await; - emit_provider_options(state, events, true).await; - } - } -} - -fn interaction_mode_target(mode: InteractionMode, state: &State) -> Option { - match mode { - InteractionMode::Plan => state.modes.as_ref().and_then(acp_plan_mode), - InteractionMode::Build => state.build_mode(), - } -} - -fn missing_mode_warning(mode: InteractionMode) -> AgentEvent { - let mode_name = match mode { - InteractionMode::Plan => "Plan", - InteractionMode::Build => "Build", - }; - AgentEvent::Warning { - message: format!( - "This agent does not advertise a {mode_name} mode; the mode switch was not applied." - ), - } -} - -/// Map our four fixed decisions onto the agent's own permission options (so the -/// existing approval UI keeps working), and pass `Option(id)` straight through. -fn approval_outcome( - decision: &ApprovalDecision, - options: &[ApprovalOption], -) -> Option { - let pick = |kinds: &[ApprovalOptionKind]| -> Option { - kinds.iter().find_map(|wanted| { - options - .iter() - .find(|option| option.kind == *wanted) - .map(|option| option.id.clone()) - }) - }; - let selected = match decision { - ApprovalDecision::Cancel => return Some(acp::RequestPermissionOutcome::Cancelled), - ApprovalDecision::Option(id) => Some(id.clone()), - ApprovalDecision::Approve => pick(&[ - ApprovalOptionKind::AllowOnce, - ApprovalOptionKind::AllowAlways, - ]), - ApprovalDecision::ApproveForSession => pick(&[ - ApprovalOptionKind::AllowAlways, - ApprovalOptionKind::AllowOnce, - ]), - ApprovalDecision::Deny => pick(&[ - ApprovalOptionKind::RejectOnce, - ApprovalOptionKind::RejectAlways, - ]), - }?; - Some(acp::RequestPermissionOutcome::Selected( - acp::SelectedPermissionOutcome::new(acp::PermissionOptionId::new(selected)), - )) -} - -async fn finish_turn( - state: &Arc>, - events: &Sender, - turn_id: &str, - outcome: TurnOutcome, -) { - let tail = state.lock_recover().flush_text(); - for event in tail { - let _ = events.send(event).await; - } - state.lock_recover().turn = None; - - let (status, message, usage) = match outcome.result { - Ok(response) => { - let usage = response.usage.as_ref().map(|usage| TokenUsage { - freshness: crate::ContextFreshness::Current, - total_processed_tokens: Some(usage.total_tokens), - input_tokens: Some(usage.input_tokens), - cached_input_tokens: usage.cached_read_tokens, - output_tokens: Some(usage.output_tokens), - ..TokenUsage::default() - }); - let (status, message) = stop_reason_status(response.stop_reason); - (status, message, usage) - } - // `-32800 request_cancelled`: the agent aborted the prompt outright - // instead of returning `stopReason: cancelled`. - Err(err) if i32::from(err.code) == -32800 => (TurnStatus::Interrupted, None, None), - Err(err) => (TurnStatus::Failed, Some(describe(&err)), None), - }; - if let Some(message) = message { - let _ = events - .send(AgentEvent::Error { - message, - fatal: false, - }) - .await; - } - // Fall back to the live context-window figure from `usage_update`. - let usage = usage.or_else(|| state.lock_recover().usage); - let _ = events - .send(AgentEvent::TurnCompleted { - turn_id: turn_id.to_string(), - status, - usage, - }) - .await; -} - -/// `stopReason` → canonical turn status, plus the message to surface (if any). -fn stop_reason_status(reason: acp::StopReason) -> (TurnStatus, Option) { - match reason { - acp::StopReason::EndTurn => (TurnStatus::Completed, None), - acp::StopReason::Cancelled => (TurnStatus::Interrupted, None), - acp::StopReason::Refusal => ( - TurnStatus::Failed, - Some("The agent refused to continue this turn.".into()), - ), - acp::StopReason::MaxTokens => ( - TurnStatus::Failed, - Some("The agent stopped: token limit reached.".into()), - ), - acp::StopReason::MaxTurnRequests => ( - TurnStatus::Failed, - Some("The agent stopped: too many model requests in one turn.".into()), - ), - other => ( - TurnStatus::Failed, - Some(format!("The agent stopped: {other:?}")), - ), - } -} - -fn prompt_blocks(text: &str, attachments: &[Attachment]) -> Vec { - let mut blocks = Vec::with_capacity(1 + attachments.len()); - blocks.push(acp::ContentBlock::Text(acp::TextContent::new( - text.to_string(), - ))); - for attachment in attachments { - blocks.push(acp::ContentBlock::Image(acp::ImageContent::new( - attachment.data_base64.clone(), - attachment.media_type.clone(), - ))); - } - blocks -} - -async fn emit_provider_options( - state: &Arc>, - events: &Sender, - emit_empty: bool, -) { - let event = state.lock_recover().provider_options(); - if matches!( - &event, - AgentEvent::ProviderOptions { descriptors, .. } if descriptors.is_empty() - ) && !emit_empty - { - return; - } - let _ = events.send(event).await; -} - -/// Where a canonical option id came from, so `SetOption` routes to the right -/// ACP method. -#[derive(Debug, Clone, PartialEq)] -enum OptionOrigin { - Mode, - Config(acp::SessionConfigId), -} - -/// The agent's self-described modes and config options, mapped onto our -/// [`OptionDescriptor`]s (which the composer's traits picker already renders). -#[derive(Default)] -struct OptionRegistry { - records: Vec, -} - -struct OptionRecord { - descriptor: OptionDescriptor, - selection: OptionSelection, - origin: OptionOrigin, -} - -impl OptionRegistry { - fn ingest( - &mut self, - modes: Option<&acp::SessionModeState>, - config: Option<&[acp::SessionConfigOption]>, - ) { - if let Some(modes) = modes { - self.upsert( - OptionDescriptor::Select { - id: MODE_OPTION_ID.to_string(), - label: "Mode".to_string(), - options: modes - .available_modes - .iter() - .map(|mode| SelectOption { - value: mode.id.0.to_string(), - label: mode.name.clone(), - description: mode.description.clone(), - }) - .collect(), - default_value: Some(modes.current_mode_id.0.to_string()), - }, - OptionOrigin::Mode, - Value::String(modes.current_mode_id.0.to_string()), - ); - } - for option in config.unwrap_or_default() { - // Protocol 1.2 replaced the standalone model state/set-model RPC - // with categorized config options. Preserve tcode's canonical ids - // while routing both model and config changes through the config RPC. - let id = match option.category.as_ref() { - Some(acp::SessionConfigOptionCategory::Mode) => MODE_OPTION_ID.to_string(), - Some(acp::SessionConfigOptionCategory::Model) => MODEL_OPTION_ID.to_string(), - _ => format!("{CONFIG_OPTION_PREFIX}{}", option.id.0), - }; - match &option.kind { - acp::SessionConfigKind::Select(select) => { - let options = match &select.options { - acp::SessionConfigSelectOptions::Ungrouped(flat) => flat - .iter() - .map(|option| SelectOption { - value: option.value.0.to_string(), - label: option.name.clone(), - description: option.description.clone(), - }) - .collect(), - acp::SessionConfigSelectOptions::Grouped(groups) => groups - .iter() - .flat_map(|group| { - group.options.iter().map(move |option| SelectOption { - value: option.value.0.to_string(), - label: format!("{} · {}", group.name, option.name), - description: option.description.clone(), - }) - }) - .collect(), - _ => Vec::new(), - }; - self.upsert( - OptionDescriptor::Select { - id, - label: option.name.clone(), - options, - default_value: Some(select.current_value.0.to_string()), - }, - OptionOrigin::Config(option.id.clone()), - Value::String(select.current_value.0.to_string()), - ); - } - acp::SessionConfigKind::Boolean(boolean) => self.upsert( - OptionDescriptor::Boolean { - id, - label: option.name.clone(), - default_value: boolean.current_value, - }, - OptionOrigin::Config(option.id.clone()), - Value::Bool(boolean.current_value), - ), - _ => log::warn!("acp: unsupported config-option kind for `{}`", option.id.0), - } - } - } - - fn upsert(&mut self, descriptor: OptionDescriptor, origin: OptionOrigin, value: Value) { - let id = descriptor_id(&descriptor).to_string(); - match self - .records - .iter_mut() - .find(|record| record.selection.id == id) - { - Some(record) => { - record.descriptor = descriptor; - record.selection.value = value; - record.origin = origin; - } - None => self.records.push(OptionRecord { - descriptor, - selection: OptionSelection { id, value }, - origin, - }), - } - } - - fn select(&mut self, id: &str, value: Value) { - if let Some(record) = self - .records - .iter_mut() - .find(|record| record.selection.id == id) - { - record.selection.value = value; - } - } - - fn origin(&self, id: &str) -> Option { - self.records - .iter() - .find(|record| record.selection.id == id) - .map(|record| record.origin.clone()) - } - - fn current_model(&self) -> Option { - self.records - .iter() - .find(|record| record.selection.id == MODEL_OPTION_ID) - .and_then(|record| record.selection.value.as_str().map(str::to_string)) - } -} - -fn descriptor_id(descriptor: &OptionDescriptor) -> &str { - match descriptor { - OptionDescriptor::Select { id, .. } | OptionDescriptor::Boolean { id, .. } => id, - } -} - -/// A tool call as last known. `tool_call_update` is a partial patch, so the -/// merged state lives here and every update re-renders the whole item. -#[derive(Debug, Clone, Default)] -struct ToolState { - title: String, - kind: acp::ToolKind, - status: acp::ToolCallStatus, - content: Vec, - locations: Vec, - raw_input: Option, - raw_output: Option, - announced: bool, -} - -/// The text block currently streaming (assistant prose or thinking). -struct TextStream { - id: String, - kind: DeltaKind, - text: String, -} - -struct State { - cwd: PathBuf, - turn: Option, - /// True while `session/load` replays history we already have on disk. - replaying: bool, - tools: HashMap, - text: Option, - approvals: HashMap, Vec)>, - approval_seq: u64, - text_seq: u64, - terminal_seq: u64, - terminals: HashMap>, - usage: Option, - options: OptionRegistry, - modes: Option, - previous_non_plan_mode: Option, -} - -impl State { - fn new(cwd: PathBuf) -> Self { - Self { - cwd, - turn: None, - replaying: false, - tools: HashMap::new(), - text: None, - approvals: HashMap::new(), - approval_seq: 0, - text_seq: 0, - terminal_seq: 0, - terminals: HashMap::new(), - usage: None, - options: OptionRegistry::default(), - modes: None, - previous_non_plan_mode: None, - } - } - - fn ingest_modes(&mut self, modes: Option<&acp::SessionModeState>) { - let Some(modes) = modes else { - return; - }; - self.modes = Some(modes.clone()); - self.select_mode(modes.current_mode_id.clone()); - self.options.ingest(Some(modes), None); - } - - fn select_mode(&mut self, mode: acp::SessionModeId) { - let is_plan = self - .modes - .as_ref() - .and_then(acp_plan_mode) - .is_some_and(|plan| plan == mode); - if !is_plan { - self.previous_non_plan_mode = Some(mode.clone()); - } - if let Some(modes) = self.modes.as_mut() { - modes.current_mode_id = mode.clone(); - } - self.options - .select(MODE_OPTION_ID, Value::String(mode.0.to_string())); - } - - fn build_mode(&self) -> Option { - let modes = self.modes.as_ref()?; - // ACP exposes no distinct default mode. Remember the last non-plan - // selection from before Plan; if the session began in Plan, fall back - // to the first advertised non-plan mode (the agent's ordering). - self.previous_non_plan_mode - .clone() - .or_else(|| first_non_plan_mode(modes)) - } - - fn provider_options(&self) -> AgentEvent { - AgentEvent::ProviderOptions { - descriptors: self - .options - .records - .iter() - .map(|record| record.descriptor.clone()) - .collect(), - selections: self - .options - .records - .iter() - .map(|record| record.selection.clone()) - .collect(), - } - } - - /// Close the open text block, emitting its final `ItemCompleted`. - fn flush_text(&mut self) -> Vec { - let Some(stream) = self.text.take() else { - return Vec::new(); - }; - if stream.text.is_empty() { - return Vec::new(); - } - let content = match stream.kind { - DeltaKind::ReasoningText => ItemContent::Reasoning { text: stream.text }, - _ => ItemContent::AssistantMessage { text: stream.text }, - }; - vec![AgentEvent::ItemCompleted(ThreadItem { - id: stream.id, - parent_item_id: None, - content, - })] - } - - /// Append a streaming chunk, opening a new item when the block changes. - fn push_text(&mut self, kind: DeltaKind, text: String) -> Vec { - let mut events = Vec::new(); - let same_block = self.text.as_ref().is_some_and(|stream| stream.kind == kind); - if !same_block { - events.extend(self.flush_text()); - self.text_seq += 1; - let prefix = match kind { - DeltaKind::ReasoningText => "thought", - _ => "msg", - }; - self.text = Some(TextStream { - id: format!("{prefix}-{}", self.text_seq), - kind, - text: String::new(), - }); - } - let stream = self.text.as_mut().expect("stream just opened"); - stream.text.push_str(&text); - events.push(AgentEvent::Delta { - item_id: stream.id.clone(), - kind, - text, - }); - events - } - - /// Map one `session/update` onto canonical events, merging our tool-call and - /// option state along the way. Pure w.r.t. the outside world — this is the - /// function the mapping tests drive. - fn apply_update(&mut self, update: acp::SessionUpdate) -> Vec { - match update { - // The app synthesizes the canonical user message at send time; - // rendering the agent's echo of it would double it. - acp::SessionUpdate::UserMessageChunk(_) => Vec::new(), - acp::SessionUpdate::AgentMessageChunk(chunk) => { - let text = content_text(&chunk.content); - if text.is_empty() { - return Vec::new(); - } - self.push_text(DeltaKind::AssistantText, text) - } - acp::SessionUpdate::AgentThoughtChunk(chunk) => { - let text = content_text(&chunk.content); - if text.is_empty() { - return Vec::new(); - } - self.push_text(DeltaKind::ReasoningText, text) - } - acp::SessionUpdate::ToolCall(call) => { - let mut events = self.flush_text(); - let id = call.tool_call_id.0.to_string(); - let tool = ToolState { - title: call.title, - kind: call.kind, - status: call.status, - content: call.content, - locations: call.locations, - raw_input: call.raw_input, - raw_output: call.raw_output, - announced: true, - }; - let item = self.tool_item(&id, &tool); - let existed = self - .tools - .insert(id, tool) - .is_some_and(|previous| previous.announced); - events.push(if existed { - AgentEvent::ItemUpdated(item) - } else { - AgentEvent::ItemStarted(item) - }); - events - } - acp::SessionUpdate::ToolCallUpdate(update) => { - let mut events = self.flush_text(); - let id = update.tool_call_id.0.to_string(); - let entry = self.tools.entry(id.clone()).or_default(); - let fields = update.fields; - if let Some(title) = fields.title { - entry.title = title; - } - if let Some(kind) = fields.kind { - entry.kind = kind; - } - if let Some(status) = fields.status { - entry.status = status; - } - // `content` and `locations` are whole-array replacements. - if let Some(content) = fields.content { - entry.content = content; - } - if let Some(locations) = fields.locations { - entry.locations = locations; - } - if let Some(raw_input) = fields.raw_input { - entry.raw_input = Some(raw_input); - } - if let Some(raw_output) = fields.raw_output { - entry.raw_output = Some(raw_output); - } - let announced = std::mem::replace(&mut entry.announced, true); - let status = entry.status; - let tool = entry.clone(); - let item = self.tool_item(&id, &tool); - events.push(match (announced, status) { - (false, _) => AgentEvent::ItemStarted(item), - (true, acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed) => { - AgentEvent::ItemCompleted(item) - } - _ => AgentEvent::ItemUpdated(item), - }); - events - } - acp::SessionUpdate::Plan(plan) => vec![AgentEvent::PlanUpdated { - turn_id: self.turn.clone(), - explanation: None, - steps: plan.entries.iter().map(plan_step).collect(), - }], - acp::SessionUpdate::AvailableCommandsUpdate(update) => { - vec![AgentEvent::ProviderCommands { - commands: update - .available_commands - .iter() - .map(|command| ProviderCommand { - name: command.name.clone(), - description: Some(command.description.clone()), - kind: ProviderCommandKind::Command, - }) - .collect(), - }] - } - acp::SessionUpdate::CurrentModeUpdate(update) => { - self.select_mode(update.current_mode_id.clone()); - vec![self.provider_options()] - } - acp::SessionUpdate::ConfigOptionUpdate(update) => { - self.options.ingest(None, Some(&update.config_options)); - vec![self.provider_options()] - } - acp::SessionUpdate::UsageUpdate(usage) => { - let usage = TokenUsage { - freshness: crate::ContextFreshness::Current, - used_tokens: Some(usage.used), - context_window: Some(usage.size), - cost_usd: usage - .cost - .as_ref() - .filter(|cost| cost.currency.eq_ignore_ascii_case("USD")) - .map(|cost| cost.amount), - ..Default::default() - }; - self.usage = Some(usage); - vec![AgentEvent::TokenUsage(usage)] - } - // Session titles are ours (the sidebar names sessions from the first - // user message), and unknown variants degrade to nothing. - _ => Vec::new(), - } - } - - /// The canonical item for a tool call, keyed by [`acp::ToolKind`]. - fn tool_item(&self, id: &str, tool: &ToolState) -> ThreadItem { - let status = map_status(tool.status); - let content = match tool.kind { - acp::ToolKind::Execute => ItemContent::CommandExecution { - command: command_of(tool), - output: self.tool_output(tool), - exit_code: self.exit_code_of(tool), - status, - }, - acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move => { - let changes = file_changes(tool); - if changes.is_empty() { - tool_call_content(tool, status, self.tool_output(tool)) - } else { - ItemContent::FileChange { changes, status } - } - } - acp::ToolKind::Think => { - let text = self.tool_output(tool); - ItemContent::Reasoning { - text: if text.is_empty() { - tool.title.clone() - } else { - text - }, - } - } - _ => tool_call_content(tool, status, self.tool_output(tool)), - }; - ThreadItem { - id: id.into(), - parent_item_id: None, - content, - } - } - - /// Everything the tool produced, as display text: its content blocks plus - /// the live output of any terminal it embedded. - fn tool_output(&self, tool: &ToolState) -> String { - let mut parts: Vec = Vec::new(); - for content in &tool.content { - match content { - acp::ToolCallContent::Content(block) => { - let text = content_text(&block.content); - if !text.is_empty() { - parts.push(text); - } - } - acp::ToolCallContent::Terminal(terminal) => { - if let Some(terminal) = self.terminals.get(terminal.terminal_id.0.as_ref()) { - let output = terminal.output.lock_recover(); - if !output.is_empty() { - parts.push(output.clone()); - } - } - } - // Diffs render as FileChange, not as text. - _ => {} - } - } - if parts.is_empty() - && let Some(raw) = &tool.raw_output - { - match raw.as_str() { - Some(text) => parts.push(text.to_string()), - None if !raw.is_null() => parts.push(raw.to_string()), - None => {} - } - } - parts.join("\n") - } - - fn exit_code_of(&self, tool: &ToolState) -> Option { - for content in &tool.content { - if let acp::ToolCallContent::Terminal(terminal) = content - && let Some(terminal) = self.terminals.get(terminal.terminal_id.0.as_ref()) - && let Some(status) = terminal.exit.lock_recover().as_ref() - { - return status.exit_code.map(|code| code as i32); - } - } - tool.raw_output - .as_ref() - .and_then(|raw| raw.get("exitCode").or_else(|| raw.get("exit_code"))) - .and_then(Value::as_i64) - .map(|code| code as i32) - } - - /// Reject any path the agent asks for that escapes the session's cwd. - fn resolve_path(&self, path: &Path) -> Result { - let absolute = if path.is_absolute() { - path.to_path_buf() - } else { - self.cwd.join(path) - }; - let normalized = normalize(&absolute); - if !normalized.starts_with(normalize(&self.cwd)) { - return Err(acp::Error::new( - -32602, - format!( - "path `{}` is outside the session working directory", - path.display() - ), - )); - } - Ok(normalized) - } -} - -/// Lexical path normalization (the file may not exist yet, so `canonicalize` is -/// not an option for writes). -fn normalize(path: &Path) -> PathBuf { - let mut out = PathBuf::new(); - for component in path.components() { - match component { - std::path::Component::ParentDir => { - out.pop(); - } - std::path::Component::CurDir => {} - other => out.push(other.as_os_str()), - } - } - out -} - -/// The shell command behind an `execute` tool call: the underlying tool's own -/// arguments when it exposed them, else the agent's title. -fn command_of(tool: &ToolState) -> String { - tool.raw_input - .as_ref() - .and_then(|input| input.get("command").or_else(|| input.get("cmd"))) - .and_then(|command| match command { - Value::String(command) => Some(command.clone()), - Value::Array(parts) => Some( - parts - .iter() - .filter_map(Value::as_str) - .collect::>() - .join(" "), - ), - _ => None, - }) - .unwrap_or_else(|| tool.title.clone()) -} - -fn tool_call_content(tool: &ToolState, status: ItemStatus, output: String) -> ItemContent { - ItemContent::ToolCall { - name: tool.title.clone(), - input: tool.raw_input.clone().unwrap_or(Value::Null), - output: (!output.is_empty()).then_some(output), - status, - } -} - -/// `diff` content blocks → canonical [`FileChange`]s (with a unified diff, so -/// the diff panel renders them like every other provider's edits). -fn file_changes(tool: &ToolState) -> Vec { - let mut changes: Vec = tool - .content - .iter() - .filter_map(|content| match content { - acp::ToolCallContent::Diff(diff) => { - let path = diff.path.to_string_lossy().into_owned(); - Some(FileChange { - kind: match (tool.kind, diff.old_text.as_deref()) { - (acp::ToolKind::Delete, _) => FileChangeKind::Delete, - (acp::ToolKind::Move, _) => FileChangeKind::Rename, - (_, None | Some("")) => FileChangeKind::Create, - _ => FileChangeKind::Modify, - }, - diff: Some(unified_diff( - &path, - diff.old_text.as_deref().unwrap_or(""), - &diff.new_text, - )), - path, - }) - } - _ => None, - }) - .collect(); - if changes.is_empty() && matches!(tool.kind, acp::ToolKind::Delete | acp::ToolKind::Move) { - // Deletes and renames carry no diff; the locations are all we get. - changes = tool - .locations - .iter() - .map(|location| FileChange { - path: location.path.to_string_lossy().into_owned(), - kind: match tool.kind { - acp::ToolKind::Delete => FileChangeKind::Delete, - _ => FileChangeKind::Rename, - }, - diff: None, - }) - .collect(); - } - changes -} - -/// A whole-file unified diff. ACP hands us before/after text rather than a -/// patch, and the diff panel wants `@@` hunks; a single hunk covering the file -/// is exactly what the panel's full-file path already renders. -fn unified_diff(path: &str, old: &str, new: &str) -> String { - let old_lines: Vec<&str> = old.lines().collect(); - let new_lines: Vec<&str> = new.lines().collect(); - let mut out = String::new(); - if old_lines.is_empty() { - out.push_str("--- /dev/null\n"); - } else { - let _ = writeln!(out, "--- a/{path}"); - } - if new_lines.is_empty() { - out.push_str("+++ /dev/null\n"); - } else { - let _ = writeln!(out, "+++ b/{path}"); - } - let _ = writeln!( - out, - "@@ -{},{} +{},{} @@", - usize::from(!old_lines.is_empty()), - old_lines.len(), - usize::from(!new_lines.is_empty()), - new_lines.len() - ); - // Trim the common prefix/suffix so the (very common) single-line edit does - // not render as a whole-file rewrite. - let prefix = old_lines - .iter() - .zip(new_lines.iter()) - .take_while(|(old, new)| old == new) - .count(); - let suffix = old_lines - .iter() - .rev() - .zip(new_lines.iter().rev()) - .take_while(|(old, new)| old == new) - .count() - .min(old_lines.len() - prefix) - .min(new_lines.len() - prefix); - for line in &old_lines[..prefix] { - let _ = writeln!(out, " {line}"); - } - for line in &old_lines[prefix..old_lines.len() - suffix] { - let _ = writeln!(out, "-{line}"); - } - for line in &new_lines[prefix..new_lines.len() - suffix] { - let _ = writeln!(out, "+{line}"); - } - for line in &old_lines[old_lines.len() - suffix..] { - let _ = writeln!(out, " {line}"); - } - out -} - -fn map_status(status: acp::ToolCallStatus) -> ItemStatus { - match status { - acp::ToolCallStatus::Pending | acp::ToolCallStatus::InProgress => ItemStatus::InProgress, - acp::ToolCallStatus::Completed => ItemStatus::Completed, - acp::ToolCallStatus::Failed => ItemStatus::Failed, - _ => ItemStatus::InProgress, - } -} - -fn plan_step(entry: &acp::PlanEntry) -> PlanStep { - PlanStep { - step: entry.content.clone(), - status: match entry.status { - acp::PlanEntryStatus::InProgress => PlanStepStatus::InProgress, - acp::PlanEntryStatus::Completed => PlanStepStatus::Completed, - _ => PlanStepStatus::Pending, - }, - } -} - -/// Displayable text for a content block (images/audio degrade to a marker). -fn content_text(block: &acp::ContentBlock) -> String { - match block { - acp::ContentBlock::Text(text) => text.text.clone(), - acp::ContentBlock::ResourceLink(link) => link.uri.clone(), - acp::ContentBlock::Resource(resource) => match &resource.resource { - acp::EmbeddedResourceResource::TextResourceContents(text) => text.text.clone(), - _ => String::new(), - }, - acp::ContentBlock::Image(_) => "[image]".to_string(), - acp::ContentBlock::Audio(_) => "[audio]".to_string(), - _ => String::new(), - } -} - -/// The approval an agent's `session/request_permission` becomes. -fn approval_request( - id: String, - turn_id: Option, - tool: &acp::ToolCallUpdate, - options: &[acp::PermissionOption], -) -> ApprovalRequest { - let fields = &tool.fields; - let title = fields.title.clone().unwrap_or_default(); - let kind = fields.kind.unwrap_or_default(); - let raw_input = fields.raw_input.clone().unwrap_or(Value::Null); - let approval_kind = match kind { - acp::ToolKind::Execute => ApprovalKind::ExecCommand { - command: command_of(&ToolState { - title: title.clone(), - raw_input: fields.raw_input.clone(), - ..Default::default() - }), - cwd: None, - reason: None, - }, - acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move => { - let changes = file_changes(&ToolState { - title: title.clone(), - kind, - content: fields.content.clone().unwrap_or_default(), - locations: fields.locations.clone().unwrap_or_default(), - raw_input: fields.raw_input.clone(), - ..Default::default() - }); - if changes.is_empty() { - ApprovalKind::ToolUse { - name: title.clone(), - input: raw_input, - detail: title, - } - } else { - ApprovalKind::FileChange { - changes, - reason: None, - } - } - } - acp::ToolKind::Read | acp::ToolKind::Search | acp::ToolKind::Fetch => { - ApprovalKind::FileRead { detail: title } - } - _ => ApprovalKind::ToolUse { - name: title.clone(), - input: raw_input, - detail: title, - }, - }; - ApprovalRequest { - id, - turn_id, - kind: approval_kind, - options: options - .iter() - .map(|option| ApprovalOption { - id: option.option_id.0.to_string(), - label: option.name.clone(), - kind: match option.kind { - acp::PermissionOptionKind::AllowOnce => ApprovalOptionKind::AllowOnce, - acp::PermissionOptionKind::AllowAlways => ApprovalOptionKind::AllowAlways, - acp::PermissionOptionKind::RejectAlways => ApprovalOptionKind::RejectAlways, - _ => ApprovalOptionKind::RejectOnce, - }, - }) - .collect(), - } -} - -#[derive(Clone)] -struct AcpClient { - events: Sender, - state: Arc>, -} - -impl AcpClient { - async fn session_notification(&self, args: acp::SessionNotification) -> Result<(), acp::Error> { - // While `session/load` replays the conversation we already have on disk, - // swallow everything: our JSONL log is the source of truth and the - // timeline was folded from it before the process even started. - if self.state.lock_recover().replaying { - return Ok(()); - } - let events = self.state.lock_recover().apply_update(args.update); - for event in events { - let _ = self.events.send(event).await; - } - Ok(()) - } - - async fn request_permission( - &self, - args: acp::RequestPermissionRequest, - ) -> Result { - let (request_id, turn) = { - let mut state = self.state.lock_recover(); - state.approval_seq += 1; - ( - format!("acp-approval-{}", state.approval_seq), - state.turn.clone(), - ) - }; - let request = approval_request(request_id.clone(), turn, &args.tool_call, &args.options); - let (responder, decided) = smol::channel::bounded(1); - { - let mut state = self.state.lock_recover(); - state - .approvals - .insert(request_id.clone(), (responder, request.options.clone())); - // Keep the tool card in step with what we are asking about. - let id = args.tool_call.tool_call_id.0.to_string(); - let entry = state.tools.entry(id).or_default(); - if let Some(title) = args.tool_call.fields.title.clone() { - entry.title = title; - } - if let Some(kind) = args.tool_call.fields.kind { - entry.kind = kind; - } - if let Some(raw_input) = args.tool_call.fields.raw_input.clone() { - entry.raw_input = Some(raw_input); - } - } - let _ = self - .events - .send(AgentEvent::ApprovalRequested(request)) - .await; - - let outcome = decided - .recv() - .await - .unwrap_or(acp::RequestPermissionOutcome::Cancelled); - self.state.lock_recover().approvals.remove(&request_id); - Ok(acp::RequestPermissionResponse::new(outcome)) - } - - async fn read_text_file( - &self, - args: acp::ReadTextFileRequest, - ) -> Result { - let path = self.state.lock_recover().resolve_path(&args.path)?; - let content = smol::fs::read_to_string(&path).await.map_err(|err| { - acp::Error::new(-32603, format!("could not read {}: {err}", path.display())) - })?; - // `line` is 1-based; `limit` counts lines from there. - let content = match (args.line, args.limit) { - (None, None) => content, - (line, limit) => { - let start = line.unwrap_or(1).saturating_sub(1) as usize; - let lines: Vec<&str> = content.lines().skip(start).collect(); - let end = limit.map_or(lines.len(), |limit| lines.len().min(limit as usize)); - lines[..end].join("\n") - } - }; - Ok(acp::ReadTextFileResponse::new(content)) - } - - async fn write_text_file( - &self, - args: acp::WriteTextFileRequest, - ) -> Result { - let path = self.state.lock_recover().resolve_path(&args.path)?; - if let Some(parent) = path.parent() { - smol::fs::create_dir_all(parent).await.map_err(|err| { - acp::Error::new( - -32603, - format!("could not create {}: {err}", parent.display()), - ) - })?; - } - smol::fs::write(&path, args.content).await.map_err(|err| { - acp::Error::new(-32603, format!("could not write {}: {err}", path.display())) - })?; - Ok(acp::WriteTextFileResponse::new()) - } - - async fn create_terminal( - &self, - args: acp::CreateTerminalRequest, - connection: &AcpConnection, - ) -> Result { - let cwd = match &args.cwd { - Some(cwd) => self.state.lock_recover().resolve_path(cwd)?, - None => self.state.lock_recover().cwd.clone(), - }; - let terminal = Terminal::spawn( - connection, - &args.command, - &args.args, - &args.env, - &cwd, - args.output_byte_limit - .unwrap_or(DEFAULT_TERMINAL_OUTPUT_LIMIT), - )?; - let id = { - let mut state = self.state.lock_recover(); - state.terminal_seq += 1; - let id = format!("term-{}", state.terminal_seq); - state.terminals.insert(id.clone(), terminal); - id - }; - Ok(acp::CreateTerminalResponse::new(acp::TerminalId::new(id))) - } - - async fn terminal_output( - &self, - args: acp::TerminalOutputRequest, - ) -> Result { - let terminal = self.terminal(&args.terminal_id)?; - let output = terminal.output.lock_recover().clone(); - let truncated = *terminal.truncated.lock_recover(); - let exit_status = terminal.exit.lock_recover().clone(); - Ok(acp::TerminalOutputResponse::new(output, truncated).exit_status(exit_status)) - } - - async fn wait_for_terminal_exit( - &self, - args: acp::WaitForTerminalExitRequest, - ) -> Result { - let terminal = self.terminal(&args.terminal_id)?; - // The sender is dropped once the process exits, closing the channel. - let _ = terminal.done.recv().await; - let exit_status = terminal - .exit - .lock_recover() - .clone() - .unwrap_or_else(acp::TerminalExitStatus::new); - Ok(acp::WaitForTerminalExitResponse::new(exit_status)) - } - - async fn kill_terminal( - &self, - args: acp::KillTerminalRequest, - ) -> Result { - self.terminal(&args.terminal_id)?.kill(); - Ok(acp::KillTerminalResponse::new()) - } - - async fn release_terminal( - &self, - args: acp::ReleaseTerminalRequest, - ) -> Result { - let terminal = self - .state - .lock_recover() - .terminals - .remove(args.terminal_id.0.as_ref()); - if let Some(terminal) = terminal { - terminal.kill(); - } - Ok(acp::ReleaseTerminalResponse::new()) - } - fn terminal(&self, id: &acp::TerminalId) -> Result, acp::Error> { - self.state - .lock_recover() - .terminals - .get(id.0.as_ref()) - .cloned() - .ok_or_else(|| acp::Error::new(-32002, format!("unknown terminal `{}`", id.0))) - } -} - -/// A command the agent asked us to run. Headless: we capture the output and -/// serve `terminal/output` / `terminal/wait_for_exit` from it, and the text is -/// folded into the owning tool card (`ToolCallContent::Terminal`). Wiring these -/// into the terminal drawer needs a canonical event the contract does not have. -struct Terminal { - child: Mutex, - output: Mutex, - truncated: Mutex, - exit: Mutex>, - /// Closed (never sent on) once the process has exited. - done: Receiver<()>, -} - -impl Terminal { - fn spawn( - connection: &AcpConnection, - command: &str, - args: &[String], - env: &[acp::EnvVariable], - cwd: &Path, - limit: u64, - ) -> Result, acp::Error> { - let program = crate::resolve_binary(None, command) - .map_err(|err| acp::Error::new(-32603, err.to_string()))?; - let mut cmd = crate::process::async_command(&program); - cmd.args(args) - .current_dir(cwd) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - for var in env { - cmd.env(&var.name, &var.value); - } - let mut child = cmd - .spawn() - .map_err(|err| acp::Error::new(-32603, format!("could not run `{command}`: {err}")))?; - let stdout = child.stdout.take().ok_or_else(|| { - acp::Error::new(-32603, format!("`{command}` started without piped stdout")) - })?; - let stderr = child.stderr.take().ok_or_else(|| { - acp::Error::new(-32603, format!("`{command}` started without piped stderr")) - })?; - let (done_tx, done) = smol::channel::bounded::<()>(1); - - let terminal = Arc::new(Terminal { - child: Mutex::new(child), - output: Mutex::new(String::new()), - truncated: Mutex::new(false), - exit: Mutex::new(None), - done, - }); - - // stdout and stderr interleave into one buffer, as they do in a terminal. - let streams: [Box; 2] = - [Box::new(stdout), Box::new(stderr)]; - for stream in streams { - connection.spawn({ - let terminal = terminal.clone(); - async move { - let mut stream = stream; - let mut buf = [0u8; 4096]; - loop { - match stream.read(&mut buf).await { - Ok(0) | Err(_) => break, - Ok(read) => terminal.append(&buf[..read], limit), - } - } - Ok(()) - } - })?; - } - - connection.spawn({ - let terminal = terminal.clone(); - async move { - loop { - let status = terminal.child.lock_recover().try_status(); - match status { - Ok(Some(status)) => { - *terminal.exit.lock_recover() = Some(exit_status(&status)); - break; - } - Err(err) => { - log::warn!("acp terminal: {err}"); - break; - } - Ok(None) => smol::Timer::after(Duration::from_millis(25)).await, - }; - } - // Closing the channel wakes every `wait_for_exit`. - drop(done_tx); - Ok(()) - } - })?; - - Ok(terminal) - } - - fn append(&self, bytes: &[u8], limit: u64) { - let mut output = self.output.lock_recover(); - output.push_str(&String::from_utf8_lossy(bytes)); - let limit = limit as usize; - if output.len() > limit { - // Keep the tail (what ACP asks for), cutting on a char boundary. - let mut cut = output.len() - limit; - while !output.is_char_boundary(cut) { - cut += 1; - } - output.drain(..cut); - *self.truncated.lock_recover() = true; - } - } - - fn kill(&self) { - let _ = self.child.lock_recover().kill(); - } -} - -fn exit_status(status: &std::process::ExitStatus) -> acp::TerminalExitStatus { - #[cfg(unix)] - let signal = { - use std::os::unix::process::ExitStatusExt as _; - status.signal().map(|signal| signal.to_string()) - }; - #[cfg(not(unix))] - let signal: Option = None; - acp::TerminalExitStatus::new() - .exit_code(status.code().map(|code| code as u32)) - .signal(signal) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn state() -> State { - State::new(PathBuf::from("/tmp/tcode-acp-test")) - } - - fn update(json: Value) -> acp::SessionUpdate { - serde_json::from_value(json).expect("valid session/update payload") - } - - fn modes(current: &str, available: &[&str]) -> acp::SessionModeState { - serde_json::from_value(json!({ - "currentModeId": current, - "availableModes": available - .iter() - .map(|id| json!({ "id": id, "name": id })) - .collect::>() - })) - .unwrap() - } - - #[test] - fn plan_mode_requires_advertisement_and_restores_the_previous_mode() { - for previous in ["build", "review"] { - let mut state = state(); - state.ingest_modes(Some(&modes(previous, &["build", "review", "plan"]))); - let plan = interaction_mode_target(InteractionMode::Plan, &state).unwrap(); - assert_eq!(plan, acp::SessionModeId::new("plan")); - state.select_mode(plan); - assert_eq!( - interaction_mode_target(InteractionMode::Build, &state), - Some(acp::SessionModeId::new(previous)) - ); - } - let mut state = state(); - state.ingest_modes(Some(&modes("build", &["build", "review"]))); - assert_eq!(interaction_mode_target(InteractionMode::Plan, &state), None); - assert!(matches!(missing_mode_warning(InteractionMode::Plan), - AgentEvent::Warning { message } if message.contains("does not advertise a Plan mode"))); - } - - #[test] - fn prompt_acceptance_follows_the_complete_stdio_write() { - smol::block_on(async { - use smol::prelude::*; - - let pending = Arc::new(Mutex::new(VecDeque::from([41]))); - let (events, received) = smol::channel::unbounded(); - let inner = smol::io::Cursor::new(Vec::new()); - let mut writer = ObservedWriter::new(inner, pending.clone(), events); - - writer - .write_all( - br#"{"jsonrpc":"2.0","method":"initialize"} -"#, - ) - .await - .unwrap(); - writer - .write_all(br#"{"jsonrpc":"2.0","method":"session/prompt"}"#) - .await - .unwrap(); - assert!( - received.try_recv().is_err(), - "an incomplete line must not ack" - ); - - writer.write_all(b"\n").await.unwrap(); - assert!(matches!( - received.recv().await.unwrap(), - AgentEvent::TurnAccepted { delivery_id: 41 } - )); - assert!(pending.lock_recover().is_empty()); - }); - } - - /// The preview MCP server is a loopback HTTP endpoint: it may only be handed - /// to agents that advertise `mcpCapabilities.http`. - #[test] - fn mcp_server_is_gated_on_the_http_capability() { - let registration = McpRegistration { - name: McpRegistration::SERVER_NAME_PREVIEW.into(), - url: "http://127.0.0.1:5321/mcp".into(), - bearer_token: "tok".into(), - }; - let mut caps = acp::AgentCapabilities::default(); - assert!( - mcp_servers(&[®istration], &caps).is_empty(), - "an agent without mcpCapabilities.http must not be sent the HTTP server" - ); - - caps.mcp_capabilities.http = true; - let servers = mcp_servers(&[®istration], &caps); - let value = serde_json::to_value(&servers[0]).unwrap(); - assert_eq!(value["type"], "http"); - assert_eq!(value["name"], "tcode_preview"); - assert_eq!(value["url"], "http://127.0.0.1:5321/mcp"); - assert_eq!(value["headers"][0]["name"], "Authorization"); - assert_eq!(value["headers"][0]["value"], "Bearer tok"); - - // No preview server running → nothing to send, capability or not. - assert!(mcp_servers(&[], &caps).is_empty()); - - let orchestrate = McpRegistration { - name: McpRegistration::SERVER_NAME_ORCHESTRATE.into(), - url: "http://127.0.0.1:5321/mcp".into(), - bearer_token: "other".into(), - }; - assert_eq!(mcp_servers(&[®istration, &orchestrate], &caps).len(), 2); - assert_eq!(mcp_servers(&[&orchestrate], &caps).len(), 1); - - let computer_use = McpRegistration { - name: McpRegistration::SERVER_NAME_COMPUTER_USE.into(), - url: "http://127.0.0.1:5322/mcp".into(), - bearer_token: "computer-token".into(), - }; - let servers = mcp_servers(&[®istration, &orchestrate, &computer_use], &caps); - assert_eq!(servers.len(), 3); - let value = serde_json::to_value(&servers[2]).unwrap(); - assert_eq!(value["name"], "tcode_computer_use"); - assert_eq!(value["headers"][0]["value"], "Bearer computer-token"); - } - - #[test] - fn streamed_text_keeps_block_identity_across_reasoning_and_ignores_user_echoes() { - let mut state = state(); - let mut previous = None; - for (kind, wire_kind, chunks, expected) in [ - ( - DeltaKind::AssistantText, - "agent_message_chunk", - ["Hel", "lo"], - "Hello", - ), - ( - DeltaKind::ReasoningText, - "agent_thought_chunk", - ["Think", "ing"], - "Thinking", - ), - ( - DeltaKind::AssistantText, - "agent_message_chunk", - ["An", "swer"], - "Answer", - ), - ] { - let mut id = None; - for chunk in chunks { - let mut events = state - .apply_update(update(json!({ - "sessionUpdate": wire_kind, "content": {"type":"text", "text":chunk} - }))) - .into_iter(); - if let Some((previous_id, previous_kind, previous_text)) = previous.take() { - let Some(AgentEvent::ItemCompleted(item)) = events.next() else { - panic!("changing text kind must complete the previous block"); - }; - assert_eq!(item.id, previous_id); - match (previous_kind, item.content) { - (DeltaKind::AssistantText, ItemContent::AssistantMessage { text }) - | (DeltaKind::ReasoningText, ItemContent::Reasoning { text }) => { - assert_eq!(text, previous_text) - } - other => panic!("wrong completed block: {other:?}"), - } - } - let Some(AgentEvent::Delta { - item_id, - kind: actual_kind, - text, - }) = events.next() - else { - panic!("text chunk must stream a delta"); - }; - assert_eq!(actual_kind, kind); - assert_eq!(text, chunk); - if let Some(id) = &id { - assert_eq!(&item_id, id); - } - id = Some(item_id); - assert!(events.next().is_none()); - assert!(state.apply_update(update(json!({ - "sessionUpdate":"user_message_chunk", "content":{"type":"text", "text":"prompt echo"} - }))).is_empty()); - } - previous = Some((id.unwrap(), kind, expected)); - } - let (id, _, expected) = previous.unwrap(); - assert!(matches!(state.flush_text().as_slice(), - [AgentEvent::ItemCompleted(ThreadItem { id: actual_id, content: ItemContent::AssistantMessage { text }, .. })] - if actual_id == &id && text == expected)); - assert!(state.flush_text().is_empty()); - } - - #[test] - fn execute_tool_call_maps_to_a_command_execution() { - let mut state = state(); - let events = state.apply_update(update(json!({ - "sessionUpdate": "tool_call", - "toolCallId": "t1", - "title": "Run tests", - "kind": "execute", - "status": "in_progress", - "rawInput": { "command": "cargo test" } - }))); - match &events[0] { - AgentEvent::ItemStarted(item) => { - assert_eq!(item.id, "t1"); - match &item.content { - ItemContent::CommandExecution { - command, status, .. - } => { - assert_eq!(command, "cargo test"); - assert_eq!(*status, ItemStatus::InProgress); - } - other => panic!("expected CommandExecution, got {other:?}"), - } - } - other => panic!("expected ItemStarted, got {other:?}"), - } - - // A partial patch merges into the merged state and completes the item. - let events = state.apply_update(update(json!({ - "sessionUpdate": "tool_call_update", - "toolCallId": "t1", - "status": "completed", - "content": [{ "type": "content", "content": { "type": "text", "text": "ok" } }], - "rawOutput": { "exitCode": 0 } - }))); - match &events[0] { - AgentEvent::ItemCompleted(item) => match &item.content { - ItemContent::CommandExecution { - command, - output, - exit_code, - status, - } => { - assert_eq!(command, "cargo test", "rawInput must survive the patch"); - assert_eq!(output, "ok"); - assert_eq!(*exit_code, Some(0)); - assert_eq!(*status, ItemStatus::Completed); - } - other => panic!("expected CommandExecution, got {other:?}"), - }, - other => panic!("expected ItemCompleted, got {other:?}"), - } - } - - #[test] - fn file_tools_preserve_operation_kind_and_render_available_change_evidence() { - for (kind, old, new, operation, diff) in [ - ( - "edit", - Some("same\nbefore\nend\n"), - "same\nafter\nend\n", - FileChangeKind::Modify, - "--- a/file.rs\n+++ b/file.rs\n@@ -1,3 +1,3 @@\n same\n-before\n+after\n end\n", - ), - ( - "edit", - None, - "new\n", - FileChangeKind::Create, - "--- /dev/null\n+++ b/file.rs\n@@ -0,0 +1,1 @@\n+new\n", - ), - ( - "edit", - Some(""), - "new\n", - FileChangeKind::Create, - "--- /dev/null\n+++ b/file.rs\n@@ -0,0 +1,1 @@\n+new\n", - ), - ( - "delete", - Some("old\n"), - "", - FileChangeKind::Delete, - "--- a/file.rs\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-old\n", - ), - ( - "move", - Some("old\n"), - "new\n", - FileChangeKind::Rename, - "--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,1 @@\n-old\n+new\n", - ), - ] { - let events = state().apply_update(update(json!({ - "sessionUpdate":"tool_call", "toolCallId":"file", "title":"Change file", - "kind":kind, "status":"completed", - "content":[{"type":"diff","path":"file.rs","oldText":old,"newText":new}] - }))); - let [ - AgentEvent::ItemStarted(ThreadItem { - content: ItemContent::FileChange { changes, status }, - .. - }), - ] = events.as_slice() - else { - panic!("{kind}: expected file item, got {events:?}"); - }; - assert_eq!(*status, ItemStatus::Completed); - assert_eq!( - changes, - &[FileChange { - path: "file.rs".into(), - kind: operation, - diff: Some(diff.into()) - }] - ); - } - for (kind, operation) in [ - ("delete", FileChangeKind::Delete), - ("move", FileChangeKind::Rename), - ] { - let events = state().apply_update(update(json!({ - "sessionUpdate":"tool_call", "toolCallId":"file", "title":"Change file", - "kind":kind, "status":"completed", "locations":[{"path":"one.rs"},{"path":"two.rs"}] - }))); - let [ - AgentEvent::ItemStarted(ThreadItem { - content: ItemContent::FileChange { changes, .. }, - .. - }), - ] = events.as_slice() - else { - panic!("{kind}: expected file locations, got {events:?}"); - }; - assert_eq!( - changes, - &[ - FileChange { - path: "one.rs".into(), - kind: operation, - diff: None - }, - FileChange { - path: "two.rs".into(), - kind: operation, - diff: None - }, - ] - ); - } - let events = state().apply_update(update(json!({ - "sessionUpdate":"tool_call", "toolCallId":"file", "title":"Opaque edit", - "kind":"edit", "status":"completed", "rawInput":{"path":"file.rs"}, "rawOutput":"done" - }))); - assert!( - matches!(events.as_slice(), [AgentEvent::ItemStarted(ThreadItem { - content:ItemContent::ToolCall { name, input, output:Some(output), status:ItemStatus::Completed }, .. - })] if name == "Opaque edit" && input == &json!({"path":"file.rs"}) && output == "done") - ); - } - - #[test] - fn read_search_fetch_and_other_map_to_tool_cards_with_raw_payloads() { - for (kind, id) in [ - ("read", "r1"), - ("search", "r2"), - ("fetch", "r3"), - ("other", "r4"), - ("switch_mode", "r5"), - ] { - let mut state = state(); - let events = state.apply_update(update(json!({ - "sessionUpdate": "tool_call", - "toolCallId": id, - "title": "Read file", - "kind": kind, - "status": "completed", - "rawInput": { "path": "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/repo/x.rs" }, - "content": [{ "type": "content", "content": { "type": "text", "text": "body" } }] - }))); - match &events[0] { - AgentEvent::ItemStarted(item) => match &item.content { - ItemContent::ToolCall { - name, - input, - output, - status, - } => { - assert_eq!(name, "Read file"); - assert_eq!(input["path"], "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/repo/x.rs", "rawInput must ride along"); - assert_eq!(output.as_deref(), Some("body")); - assert_eq!(*status, ItemStatus::Completed); - } - other => panic!("{kind}: expected ToolCall, got {other:?}"), - }, - other => panic!("{kind}: expected ItemStarted, got {other:?}"), - } - } - } - - #[test] - fn think_tool_calls_become_reasoning() { - let mut state = state(); - let events = state.apply_update(update(json!({ - "sessionUpdate": "tool_call", - "toolCallId": "t4", - "title": "Thinking", - "kind": "think", - "status": "completed", - "content": [{ "type": "content", "content": { "type": "text", "text": "step 1" } }] - }))); - match &events[0] { - AgentEvent::ItemStarted(item) => match &item.content { - ItemContent::Reasoning { text } => assert_eq!(text, "step 1"), - other => panic!("expected Reasoning, got {other:?}"), - }, - other => panic!("expected ItemStarted, got {other:?}"), - } - } - - #[test] - fn tool_updates_recover_missing_starts_and_preserve_status_and_input() { - for (wire, expected) in [ - ("pending", ItemStatus::InProgress), - ("in_progress", ItemStatus::InProgress), - ("completed", ItemStatus::Completed), - ("failed", ItemStatus::Failed), - ] { - let mut state = state(); - let events = state.apply_update(update(json!({ - "sessionUpdate":"tool_call_update", "toolCallId":"late", "title":"Late tool", - "kind":"other", "status":"pending", "rawInput":{"path":"a.rs"} - }))); - assert!(matches!(events.as_slice(), [AgentEvent::ItemStarted(_)])); - let events = state.apply_update(update(json!({ - "sessionUpdate":"tool_call_update", "toolCallId":"late", "status":wire - }))); - let item = match events.as_slice() { - [AgentEvent::ItemUpdated(item)] if expected == ItemStatus::InProgress => item, - [AgentEvent::ItemCompleted(item)] if expected != ItemStatus::InProgress => item, - other => panic!("{wire}: incorrect lifecycle event {other:?}"), - }; - assert!( - matches!(&item.content, ItemContent::ToolCall { name, input, status, .. } - if name == "Late tool" && input == &json!({"path":"a.rs"}) && *status == expected) - ); - } - } - - #[test] - fn plan_update_preserves_turn_and_steps() { - let mut state = state(); - state.turn = Some("turn-1".into()); - let events = state.apply_update(update(json!({ - "sessionUpdate": "plan", - "entries": [ - { "content": "Read the code", "priority": "high", "status": "completed" }, - { "content": "Write the fix", "priority": "medium", "status": "in_progress" } - ] - }))); - match &events[0] { - AgentEvent::PlanUpdated { - turn_id, - steps, - explanation, - } => { - assert_eq!(turn_id.as_deref(), Some("turn-1")); - assert!(explanation.is_none()); - assert_eq!(steps.len(), 2); - assert_eq!(steps[0].step, "Read the code"); - assert_eq!(steps[0].status, PlanStepStatus::Completed); - assert_eq!(steps[1].status, PlanStepStatus::InProgress); - } - other => panic!("expected PlanUpdated, got {other:?}"), - } - } - - #[test] - fn available_commands_become_provider_commands() { - let mut state = state(); - let events = state.apply_update(update(json!({ - "sessionUpdate": "available_commands_update", - "availableCommands": [ - { "name": "review", "description": "Review the diff", "input": null } - ] - }))); - match &events[0] { - AgentEvent::ProviderCommands { commands } => { - assert_eq!(commands[0].name, "review"); - assert_eq!(commands[0].kind, ProviderCommandKind::Command); - } - other => panic!("expected ProviderCommands, got {other:?}"), - } - } - - #[test] - fn usage_update_is_the_context_window() { - let mut state = state(); - let events = state.apply_update(update(json!({ - "sessionUpdate": "usage_update", - "used": 1200, - "size": 200000 - }))); - match &events[0] { - AgentEvent::TokenUsage(usage) => { - assert_eq!(usage.used_tokens, Some(1200)); - assert_eq!(usage.context_window, Some(200_000)); - } - other => panic!("expected TokenUsage, got {other:?}"), - } - } - - #[test] - fn modes_and_categorized_config_become_provider_options() { - let mut state = state(); - let modes: acp::SessionModeState = serde_json::from_value(json!({ - "currentModeId": "build", - "availableModes": [ - { "id": "build", "name": "Build" }, - { "id": "plan", "name": "Plan", "description": "Read-only" } - ] - })) - .unwrap(); - let config: Vec = serde_json::from_value(json!([ - { - "id": "model", - "name": "Model", - "category": "model", - "type": "select", - "currentValue": "sonnet", - "options": [{ "value": "sonnet", "name": "Sonnet" }] - }, - { - "id": "thought_level", - "name": "Thinking", - "category": "thought_level", - "type": "select", - "currentValue": "medium", - "options": [ - { "value": "low", "name": "Low" }, - { "value": "medium", "name": "Medium" } - ] - }, - { "id": "web", "name": "Web search", "type": "boolean", "currentValue": true } - ])) - .unwrap(); - state.options.ingest(Some(&modes), Some(&config)); - assert_eq!(state.options.current_model(), Some("sonnet".to_string())); - - let descriptors: Vec<_> = state - .options - .records - .iter() - .map(|record| record.descriptor.clone()) - .collect(); - let ids: Vec<&str> = descriptors.iter().map(descriptor_id).collect(); - assert_eq!( - ids, - vec![ - "acp:mode", - "acp:model", - "acp:cfg:thought_level", - "acp:cfg:web" - ] - ); - assert_eq!(state.options.origin("acp:mode"), Some(OptionOrigin::Mode)); - assert_eq!( - state.options.origin("acp:model"), - Some(OptionOrigin::Config(acp::SessionConfigId::new("model"))) - ); - assert_eq!( - state.options.origin("acp:cfg:web"), - Some(OptionOrigin::Config(acp::SessionConfigId::new("web"))) - ); - assert!(matches!( - &descriptors[3], - OptionDescriptor::Boolean { - default_value: true, - .. - } - )); - - let selections: Vec<_> = state - .options - .records - .iter() - .map(|record| record.selection.clone()) - .collect(); - assert_eq!(selections[0].value, json!("build")); - assert_eq!(selections[1].value, json!("sonnet")); - assert_eq!(selections[2].value, json!("medium")); - assert_eq!(selections[3].value, json!(true)); - - // An agent-initiated mode switch re-publishes the options… - let events = state.apply_update(update(json!({ - "sessionUpdate": "current_mode_update", - "currentModeId": "plan" - }))); - match &events[0] { - AgentEvent::ProviderOptions { selections, .. } => { - assert_eq!(selections[0].value, json!("plan")); - } - other => panic!("expected ProviderOptions, got {other:?}"), - } - - // …and so does a config-option push. - let events = state.apply_update(update(json!({ - "sessionUpdate": "config_option_update", - "configOptions": [ - { "id": "web", "name": "Web search", "type": "boolean", "currentValue": false } - ] - }))); - match &events[0] { - AgentEvent::ProviderOptions { selections, .. } => { - let web = selections.iter().find(|s| s.id == "acp:cfg:web").unwrap(); - assert_eq!(web.value, json!(false)); - } - other => panic!("expected ProviderOptions, got {other:?}"), - } - } - - #[test] - fn permission_request_carries_the_agents_own_options() { - let request: acp::RequestPermissionRequest = serde_json::from_value(json!({ - "sessionId": "s1", - "toolCall": { - "toolCallId": "t9", - "title": "rm -rf build", - "kind": "execute", - "rawInput": { "command": "rm -rf build" } - }, - "options": [ - { "optionId": "yes", "name": "Allow", "kind": "allow_once" }, - { "optionId": "always", "name": "Always allow", "kind": "allow_always" }, - { "optionId": "no", "name": "Reject", "kind": "reject_once" } - ] - })) - .unwrap(); - let approval = approval_request( - "acp-approval-1".into(), - Some("turn-1".into()), - &request.tool_call, - &request.options, - ); - match &approval.kind { - ApprovalKind::ExecCommand { command, .. } => assert_eq!(command, "rm -rf build"), - other => panic!("expected ExecCommand, got {other:?}"), - } - assert_eq!(approval.options.len(), 3); - assert_eq!(approval.options[0].label, "Allow"); - assert_eq!(approval.options[1].kind, ApprovalOptionKind::AllowAlways); - - // Our fixed four map onto the agent's own options… - let selected = - |decision: ApprovalDecision| match approval_outcome(&decision, &approval.options) { - Some(acp::RequestPermissionOutcome::Selected(outcome)) => { - outcome.option_id.0.to_string() - } - other => panic!("expected a selection, got {other:?}"), - }; - assert_eq!(selected(ApprovalDecision::Approve), "yes"); - assert_eq!(selected(ApprovalDecision::ApproveForSession), "always"); - assert_eq!(selected(ApprovalDecision::Deny), "no"); - assert_eq!( - selected(ApprovalDecision::Option("always".into())), - "always" - ); - // …and Cancel is the protocol's own `cancelled` outcome. - assert!(matches!( - approval_outcome(&ApprovalDecision::Cancel, &approval.options), - Some(acp::RequestPermissionOutcome::Cancelled) - )); - - // The exact wire shape the agent expects back. - let response = acp::RequestPermissionResponse::new( - approval_outcome(&ApprovalDecision::Approve, &approval.options).unwrap(), - ); - let value = serde_json::to_value(&response).unwrap(); - assert_eq!(value["outcome"]["outcome"], "selected"); - assert_eq!(value["outcome"]["optionId"], "yes"); - let rejections_only = vec![approval.options[2].clone()]; - for decision in [ - ApprovalDecision::Approve, - ApprovalDecision::ApproveForSession, - ] { - assert!(approval_outcome(&decision, &rejections_only).is_none()); - } - } - - #[test] - fn approvals_classify_by_tool_kind() { - let build = |kind: &str| { - let tool: acp::ToolCallUpdate = serde_json::from_value(json!({ - "toolCallId": "t", - "title": "Read config", - "kind": kind, - "rawInput": { "path": "a.rs" } - })) - .unwrap(); - approval_request("a1".into(), None, &tool, &[]).kind - }; - assert!(matches!(build("read"), ApprovalKind::FileRead { .. })); - assert!(matches!(build("search"), ApprovalKind::FileRead { .. })); - assert!(matches!(build("fetch"), ApprovalKind::FileRead { .. })); - assert!(matches!(build("other"), ApprovalKind::ToolUse { .. })); - - let edit: acp::ToolCallUpdate = serde_json::from_value(json!({ - "toolCallId": "t", - "title": "Edit", - "kind": "edit", - "content": [{ "type": "diff", "path": "a.rs", "oldText": "a\n", "newText": "b\n" }] - })) - .unwrap(); - match approval_request("a2".into(), None, &edit, &[]).kind { - ApprovalKind::FileChange { changes, .. } => assert_eq!(changes[0].path, "a.rs"), - other => panic!("expected FileChange, got {other:?}"), - } - } - - #[test] - fn stop_reasons_map_to_turn_status() { - assert_eq!( - stop_reason_status(acp::StopReason::EndTurn).0, - TurnStatus::Completed - ); - assert_eq!( - stop_reason_status(acp::StopReason::Cancelled).0, - TurnStatus::Interrupted - ); - let (status, message) = stop_reason_status(acp::StopReason::Refusal); - assert_eq!(status, TurnStatus::Failed); - assert!(message.unwrap().contains("refused")); - let (status, message) = stop_reason_status(acp::StopReason::MaxTokens); - assert_eq!(status, TurnStatus::Failed); - assert!(message.unwrap().contains("token limit")); - } - - #[test] - fn paths_outside_the_session_cwd_are_rejected() { - let state = state(); - assert!(state.resolve_path(Path::new("src/main.rs")).is_ok()); - assert!( - state - .resolve_path(Path::new("/tmp/tcode-acp-test/../etc/passwd")) - .is_err() - ); - assert!(state.resolve_path(Path::new("/etc/passwd")).is_err()); - } - - #[test] - fn prompt_carries_text_and_image_blocks() { - let blocks = prompt_blocks( - "hello", - &[Attachment { - media_type: "image/png".into(), - data_base64: "AAAA".into(), - source_path: None, - }], - ); - let value = serde_json::to_value(&blocks).unwrap(); - assert_eq!(value[0]["type"], "text"); - assert_eq!(value[0]["text"], "hello"); - assert_eq!(value[1]["type"], "image"); - assert_eq!(value[1]["mimeType"], "image/png"); - assert_eq!(value[1]["data"], "AAAA"); - } -} diff --git a/crates/agent/src/acp_session.rs b/crates/agent/src/acp_session.rs new file mode 100644 index 000000000..a0569ec3b --- /dev/null +++ b/crates/agent/src/acp_session.rs @@ -0,0 +1,3583 @@ +//! The Agent Client Protocol session shared by every ACP-speaking client: the +//! registry client (`acp.rs`) and the native Cursor and Grok clients. +//! +//! One child process per session, JSON-RPC over its stdio, driven by an +//! `agent_client_protocol::Client` connection on a dedicated thread running a +//! `LocalExecutor`. This module owns the protocol machinery: child and +//! connection lifetime, stderr and EOF handling, prompt delivery +//! acknowledgement, permission settlement, the standard fs/terminal client +//! services, MCP and option mapping, the standard `session/update` mapping and +//! cancellation. What an agent family does differently is its [`Dialect`]. + +use std::collections::{HashMap, VecDeque}; +use std::fmt::Write as _; +use std::future::Future; +use std::io; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::rc::Rc; +use std::sync::{ + Arc, Mutex, MutexGuard, + atomic::{AtomicBool, Ordering}, +}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use agent_client_protocol::{self as sdk, schema::ProtocolVersion, schema::v1 as acp}; +use serde::Deserialize as _; +use serde_json::{Map, Value}; +use smol::channel::{Receiver, Sender}; +use smol::future; +use smol::io::{AsyncRead, AsyncReadExt as _, AsyncWrite}; +use smol::prelude::*; + +use crate::{ + AgentError, AgentEvent, ApprovalDecision, ApprovalKind, ApprovalMode, ApprovalOption, + ApprovalOptionKind, ApprovalRequest, Attachment, DeltaKind, FileChange, FileChangeKind, + InteractionMode, ItemContent, ItemStatus, McpRegistration, OptionDescriptor, OptionSelection, + PlanStep, PlanStepStatus, ProviderCommand, ProviderCommandKind, ProviderKind, ResumeCursor, + SelectOption, SessionCommand, SessionHandle, SessionOptions, ThreadItem, TokenUsage, + TurnStatus, UserInputDelivery, UserInputQuestion, +}; + +/// Option-descriptor ids. The composer renders an ACP agent's own +/// modes/models/config options through the existing traits picker, so each one +/// needs a stable id that routes back to the right ACP method. +const MODE_OPTION_ID: &str = "acp:mode"; +const MODEL_OPTION_ID: &str = "acp:model"; +const CONFIG_OPTION_PREFIX: &str = "acp:cfg:"; + +/// The persisted selection for the session config option `config_id`, as +/// [`SessionOptions::option_selections`] carries it into a new process. +pub(crate) fn config_selection<'a>( + selections: &'a [OptionSelection], + config_id: &str, +) -> Option<&'a str> { + selections + .iter() + .find(|selection| { + selection + .id + .strip_prefix(CONFIG_OPTION_PREFIX) + .is_some_and(|id| id == config_id) + }) + .and_then(|selection| selection.value.as_str()) +} + +/// Cap on captured terminal output when the agent sets none (1 MiB). +const DEFAULT_TERMINAL_OUTPUT_LIMIT: u64 = 1 << 20; + +/// How long we wait for an agent to answer `initialize` before declaring it +/// broken. Generous: an `npm exec` recipe may have to fetch the package first. +const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(120); + +pub(crate) type Connection = sdk::ConnectionTo; + +trait MutexExt { + fn lock_recover(&self) -> MutexGuard<'_, T>; +} + +impl MutexExt for Mutex { + fn lock_recover(&self) -> MutexGuard<'_, T> { + self.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +macro_rules! request_handler { + ($client:expr, $request:ty, $method:ident) => {{ + let client = $client.clone(); + async move |args: $request, responder, connection| { + let client = client.clone(); + connection + .spawn(async move { responder.respond_with_result(client.$method(args).await) })?; + Ok(()) + } + }}; + ($client:expr, $request:ty, $method:ident, connection) => {{ + let client = $client.clone(); + async move |args: $request, responder, connection| { + let client = client.clone(); + let task_connection = connection.clone(); + connection.spawn(async move { + responder.respond_with_result(client.$method(args, &task_connection).await) + })?; + Ok(()) + } + }}; +} + +/// What one ACP agent family does on top of the shared protocol machinery. +/// +/// Each required method is a policy the dialect states for itself (launch, +/// authentication, session establishment, replay suppression, turn completion, +/// steering, approval-mode changes); only the extension routing defaults to +/// "no vendor methods", the client services to "offered", and option +/// ownership and automatic approval to "none". +pub(crate) trait Dialect: Send + Sync + 'static { + /// The agent's name in messages and logs. + fn name(&self) -> &str; + + /// The process to spawn and how the client introduces itself to it. + fn launch(&self, opts: &SessionOptions) -> Result; + + /// Authenticate when needed, then create, load, resume or fork the session + /// and apply its initial mode. Runs after `initialize` succeeded. + async fn establish(&self, setup: &Setup<'_>) -> Result; + + /// One `session/update`, including whether a replayed update is dropped. + /// [`State::apply_update`] is the standard mapping. + fn session_update( + &self, + state: &mut State, + notification: acp::SessionNotification, + ) -> Vec; + + /// How the `session/prompt` result ends the turn, with its usage. + fn turn_end( + &self, + state: &mut State, + result: Result, + ) -> TurnEnd; + + /// Deliver [`SessionCommand::Steer`] into the running turn. + async fn steer( + &self, + session: &Session, + request_id: String, + text: String, + attachments: Vec, + ); + + /// [`SessionCommand::SetApprovalMode`] on a live session. + async fn set_approval_mode(&self, session: &Session, mode: ApprovalMode); + + /// Whether an agent→client request outside the standard set belongs to + /// this dialect, by its literal method name. + fn handles_request(&self, _method: &str) -> bool { + false + } + + /// Answer a request [`Self::handles_request`] claimed, with its raw params. + /// It runs as its own task, so it may wait for the user without holding + /// up the connection. + fn request( + &self, + _session: Session, + method: String, + _params: Value, + ) -> impl Future> + Send { + async move { Err(acp::Error::method_not_found().data(method)) } + } + + /// An agent→client notification other than a `session/update` the schema + /// knows (a vendor `sessionUpdate` kind arrives here as `session/update`), + /// by its literal method name with raw params. Runs in arrival order with + /// the session updates. + fn notification(&self, _state: &mut State, _method: &str, _params: Value) -> Vec { + Vec::new() + } + + /// Whether `initialize` offers the client's `fs/*` and `terminal/*` + /// services. An agent offered them runs its file and shell tools through + /// tcode instead of its own implementation. + fn client_services(&self) -> bool { + true + } + + /// Session config options, by `configId`, that tcode already controls + /// elsewhere (the composer's model picker), so they are not surfaced again + /// in [`AgentEvent::ProviderOptions`]. + fn owned_config_options(&self) -> &'static [&'static str] { + &[] + } + + /// Whether a permission request for this tool call is approved without + /// asking the user: an approval mode the agent does not apply itself. + /// Only the request's allow-once option is chosen automatically; without + /// one the user is asked. + fn auto_approves(&self, _tool_call: &acp::ToolCallUpdate) -> bool { + false + } +} + +/// The process a dialect runs and its `initialize` introduction. +pub(crate) struct Launch { + pub(crate) program: PathBuf, + /// The complete argument list, including the user's launch arguments. + pub(crate) args: Vec, + /// Applied before the profile's configured environment, which wins. + pub(crate) env: Vec<(String, String)>, + /// `clientCapabilities._meta` sent with `initialize`. + pub(crate) client_meta: Option, +} + +/// The session a dialect settled on. +pub(crate) struct Established { + pub(crate) session_id: acp::SessionId, + pub(crate) resume: ResumeCursor, +} + +/// The canonical end of one prompt turn. +pub(crate) struct TurnEnd { + pub(crate) status: TurnStatus, + /// Surfaced as a non-fatal [`AgentEvent::Error`] before the completion. + pub(crate) message: Option, + pub(crate) usage: Option, +} + +/// What [`Dialect::establish`] works with. +pub(crate) struct Setup<'a> { + pub(crate) connection: &'a Connection, + pub(crate) opts: &'a SessionOptions, + pub(crate) init: &'a acp::InitializeResponse, + name: &'a str, + state: &'a Arc>, + events: &'a Sender, +} + +impl Setup<'_> { + pub(crate) async fn warn(&self, message: String) { + let _ = self.events.send(AgentEvent::Warning { message }).await; + } + + /// The `mcpServers` for `session/new`, `load` or `resume`. tcode's MCP + /// servers are loopback streamable-HTTP endpoints, so they are only + /// offered to agents that speak MCP over HTTP. + pub(crate) async fn mcp_servers(&self) -> Vec { + let registrations: Vec<_> = self.opts.mcp_servers.iter().collect(); + let servers = mcp_servers(®istrations, &self.init.agent_capabilities); + if !registrations.is_empty() && servers.is_empty() { + log::info!( + "acp[{}]: no mcpCapabilities.http; Tcode MCP servers are not registered", + self.name + ); + self.warn(format!( + "{} does not support HTTP MCP servers; Tcode MCP tools are unavailable in this session", + self.name + )) + .await; + } + servers + } + + /// `session/load`, with [`State::loading`] set until it answers: an agent + /// may replay the conversation as `session/update`s before it responds. + pub(crate) async fn load( + &self, + request: acp::LoadSessionRequest, + ) -> Result { + self.state.lock_recover().loading = true; + let (loaded_tx, loaded) = smol::channel::bounded(1); + let state = self.state.clone(); + // Cleared while the response holds the dispatch loop: an update the + // agent sends right after answering is live, and with `block_task` + // it could be read before the flag clears and dropped as replay. + let registered = + self.connection + .send_request(request) + .on_receiving_result(move |result| async move { + state.lock_recover().loading = false; + let _ = loaded_tx.send(result).await; + Ok(()) + }); + let loaded = match registered { + Ok(()) => loaded.recv().await.unwrap_or_else(|_| { + Err(acp::Error::internal_error().data("the agent closed before answering")) + }), + Err(err) => Err(err), + }; + self.state.lock_recover().loading = false; + loaded + } + + /// Take the modes and config options the session started with. + pub(crate) fn adopt( + &self, + modes: Option<&acp::SessionModeState>, + config_options: Option<&[acp::SessionConfigOption]>, + ) { + let mut state = self.state.lock_recover(); + state.ingest_modes(modes); + state.options.ingest(None, config_options); + } + + /// Whether the adopted session is currently in the agent's Plan mode. + pub(crate) fn in_plan_mode(&self) -> bool { + self.state + .lock_recover() + .modes + .as_ref() + .is_some_and(|modes| acp_plan_mode(modes).as_ref() == Some(&modes.current_mode_id)) + } + + pub(crate) async fn apply_interaction_mode( + &self, + session_id: &acp::SessionId, + mode: InteractionMode, + ) { + apply_interaction_mode(mode, self.state, self.events, self.connection, session_id).await; + } +} + +/// A live session as dialect hooks see it. +#[derive(Clone)] +pub(crate) struct Session { + pub(crate) connection: Connection, + events: Sender, + state: Arc>, +} + +impl Session { + /// The established session's id. + pub(crate) fn id(&self) -> Option { + self.with_state(|state| state.session_id.clone()) + } + + pub(crate) async fn emit(&self, event: AgentEvent) { + let _ = self.events.send(event).await; + } + + pub(crate) fn with_state(&self, f: impl FnOnce(&mut State) -> R) -> R { + f(&mut self.state.lock_recover()) + } + + /// Ask the user and wait for [`SessionCommand::RespondUserInput`]. `None` + /// when the request was cancelled by an interrupt or shutdown. + pub(crate) async fn ask_user( + &self, + questions: Vec, + ) -> Option> { + let (answer, answered) = smol::channel::bounded(1); + let request_id = self.with_state(|state| { + state.input_seq += 1; + let request_id = format!("acp-input-{}", state.input_seq); + state.inputs.insert(request_id.clone(), answer); + request_id + }); + self.emit(AgentEvent::UserInputRequested { + request_id, + questions, + delivery: UserInputDelivery::Blocking, + }) + .await; + answered.recv().await.ok() + } +} + +/// Start (or resume) a session with an ACP agent speaking `dialect`. +pub(crate) async fn start( + provider: ProviderKind, + dialect: D, + opts: SessionOptions, +) -> Result { + let (commands_tx, commands_rx) = smol::channel::unbounded(); + let (events_tx, events_rx) = smol::channel::unbounded(); + let (ready_tx, ready_rx) = smol::channel::bounded(1); + let dialect = Arc::new(dialect); + + // Keep each ACP connection and all of its callbacks on one dedicated + // executor thread. The SDK requires Send handlers internally, but the + // adapter still exposes only channels across this boundary. + std::thread::Builder::new() + .name("acp-session".into()) + .spawn(move || { + let executor = Rc::new(smol::LocalExecutor::new()); + let task = run_actor( + executor.clone(), + provider, + dialect, + opts, + commands_rx, + events_tx, + ready_tx, + ); + smol::block_on(executor.run(task)); + }) + .map_err(|err| { + AgentError::Spawn(format!("could not start the ACP session thread: {err}")) + })?; + + ready_rx.recv().await.map_err(|_| { + AgentError::Protocol("ACP actor exited before reporting startup status".into()) + })??; + + Ok(SessionHandle { + provider, + commands: commands_tx, + events: events_rx, + }) +} + +/// Run `query` against a freshly started and initialized agent outside any +/// session, then tear the agent down. Model catalogs use it. +pub(crate) async fn query( + provider: ProviderKind, + dialect: D, + opts: SessionOptions, + query: impl AsyncFnOnce(&Connection, &acp::InitializeResponse) -> Result + + Send + + 'static, +) -> Result +where + D: Dialect, + R: Send + 'static, +{ + let (result_tx, result) = smol::channel::bounded(1); + std::thread::Builder::new() + .name("acp-query".into()) + .spawn(move || { + let outcome = smol::block_on(async move { + let launch = dialect.launch(&opts)?; + let name = dialect.name().to_string(); + let client_services = dialect.client_services(); + let mut child = spawn_agent(&name, provider, &launch, &opts)?; + let (Some(stdin), Some(stdout), Some(stderr)) = + (child.stdin.take(), child.stdout.take(), child.stderr.take()) + else { + let _ = child.kill(); + return Err(AgentError::Spawn(format!( + "ACP agent `{name}` started without piped stdio" + ))); + }; + // Drained so a chatty agent never blocks on a full stderr pipe. + smol::spawn(async move { + let mut lines = smol::io::BufReader::new(stderr).lines(); + while let Some(Ok(_)) = lines.next().await {} + }) + .detach(); + let outcome = sdk::Client + .builder() + .name(format!("tcode-acp-query-{name}")) + .connect_with(sdk::ByteStreams::new(stdin, stdout), async |connection| { + Ok( + match initialize(&connection, &name, &launch, client_services).await { + Ok(init) => query(&connection, &init).await, + Err(err) => Err(err), + }, + ) + }) + .await; + let _ = child.kill(); + let _ = child.status().await; + outcome.unwrap_or_else(|err| { + Err(AgentError::Protocol(format!( + "ACP transport error: {}", + describe(&err) + ))) + }) + }); + let _ = result_tx.send_blocking(outcome); + }) + .map_err(|err| AgentError::Spawn(format!("could not start the ACP query thread: {err}")))?; + result + .recv() + .await + .map_err(|_| AgentError::Protocol("ACP query exited without a result".into()))? +} + +fn spawn_agent( + name: &str, + provider: ProviderKind, + launch: &Launch, + opts: &SessionOptions, +) -> Result { + let mut cmd = crate::process::async_command(&launch.program); + cmd.args(&launch.args) + .current_dir(&opts.cwd) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + // The dialect's env first, the user's configured env last (the user always wins). + for (key, value) in &launch.env { + cmd.env(key, value); + } + for (key, value) in opts.launch_env.pairs(provider) { + cmd.env(key, value); + } + log::debug!( + "spawning ACP agent {name}: {} {:?}", + launch.program.display(), + launch.args + ); + cmd.spawn().map_err(|err| { + AgentError::Spawn(format!( + "could not launch ACP agent `{name}` ({}): {err}", + launch.program.display() + )) + }) +} + +#[allow(clippy::too_many_arguments)] +async fn run_actor( + executor: Rc>, + provider: ProviderKind, + dialect: Arc, + opts: SessionOptions, + commands: Receiver, + events: Sender, + ready: Sender>, +) { + let launch = match dialect.launch(&opts) { + Ok(launch) => launch, + Err(err) => { + let _ = ready.send(Err(err)).await; + return; + } + }; + let name = dialect.name().to_string(); + let mut child = match spawn_agent(&name, provider, &launch, &opts) { + Ok(child) => child, + Err(err) => { + let _ = ready.send(Err(err)).await; + return; + } + }; + let Some(stdin) = child.stdin.take() else { + let _ = ready + .send(Err(AgentError::Spawn(format!( + "ACP agent `{name}` started without piped stdin" + )))) + .await; + return; + }; + let Some(stdout) = child.stdout.take() else { + let _ = ready + .send(Err(AgentError::Spawn(format!( + "ACP agent `{name}` started without piped stdout" + )))) + .await; + return; + }; + let Some(stderr) = child.stderr.take() else { + let _ = ready + .send(Err(AgentError::Spawn(format!( + "ACP agent `{name}` started without piped stderr" + )))) + .await; + return; + }; + + // The agent's stderr is its log channel: keep the tail so a startup failure + // can be reported in the agent's own words. + let stderr_tail = crate::process::StderrTail::default(); + executor + .spawn({ + let tail = stderr_tail.clone(); + let name = name.clone(); + async move { + let mut lines = smol::io::BufReader::new(stderr).lines(); + while let Some(Ok(line)) = lines.next().await { + log::debug!("acp[{name}] stderr: {line}"); + tail.push(line); + } + } + }) + .detach(); + + let mut state = State::new(opts.cwd.clone()); + state.owned_options = dialect.owned_config_options(); + let state = Arc::new(Mutex::new(state)); + let client = Client { + events: events.clone(), + state: state.clone(), + }; + let (io_done_tx, io_done) = smol::channel::bounded::(1); + let pending_deliveries = Arc::new(Mutex::new(VecDeque::new())); + let transport = sdk::ByteStreams::new( + ObservedWriter::new(stdin, pending_deliveries.clone(), events.clone()), + ObservedReader::new(stdout, io_done_tx), + ); + let session_started = Arc::new(AtomicBool::new(false)); + let connection_result = sdk::Client + .builder() + .name(format!("tcode-acp-{name}")) + // Untyped, so a `sessionUpdate` kind the schema does not know reaches + // the dialect instead of failing to parse and being dropped. + .on_receive_notification( + { + let client = client.clone(); + let dialect = dialect.clone(); + async move |notification: sdk::UntypedMessage, _connection| { + let (method, params) = notification.into_parts(); + let update = (method == "session/update") + .then(|| acp::SessionNotification::deserialize(¶ms).ok()) + .flatten(); + let events = { + let mut state = client.state.lock_recover(); + match update { + Some(update) => dialect.session_update(&mut state, update), + None => dialect.notification(&mut state, &method, params), + } + }; + client.emit_all(events).await; + Ok(()) + } + }, + sdk::on_receive_notification!(), + ) + .on_receive_request( + { + let client = client.clone(); + let dialect = dialect.clone(); + async move |args: acp::RequestPermissionRequest, responder, connection| { + let client = client.clone(); + let automatic = dialect + .auto_approves(&args.tool_call) + .then(|| allow_once(&args.options)) + .flatten(); + connection.spawn(async move { + responder.respond_with_result(match automatic { + Some(option) => Ok(acp::RequestPermissionResponse::new( + acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome::new(option), + ), + )), + None => client.request_permission(args).await, + }) + })?; + Ok(()) + } + }, + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!(client, acp::ReadTextFileRequest, read_text_file), + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!(client, acp::WriteTextFileRequest, write_text_file), + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!( + client, + acp::CreateTerminalRequest, + create_terminal, + connection + ), + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!(client, acp::TerminalOutputRequest, terminal_output), + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!( + client, + acp::WaitForTerminalExitRequest, + wait_for_terminal_exit + ), + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!(client, acp::KillTerminalRequest, kill_terminal), + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!(client, acp::ReleaseTerminalRequest, release_terminal), + sdk::on_receive_request!(), + ) + // Vendor methods are routed by their literal names. The schema's own + // extension fallback only recognises `_`-prefixed methods and strips + // the prefix, so it cannot carry Cursor's `cursor/*` methods. + .on_receive_request( + { + let client = client.clone(); + let dialect = dialect.clone(); + async move |request: sdk::UntypedMessage, responder, connection| { + if !dialect.handles_request(&request.method) { + return Ok(sdk::Handled::No { + message: (request, responder), + retry: false, + }); + } + let session = client.session(connection.clone()); + let dialect = dialect.clone(); + let (method, params) = request.into_parts(); + connection.spawn(async move { + responder + .respond_with_result(dialect.request(session, method, params).await) + })?; + Ok(sdk::Handled::Yes) + } + }, + sdk::on_receive_request!(), + ) + .connect_with(transport, { + let session_started = session_started.clone(); + let stderr_tail = stderr_tail.clone(); + let actor_events = events.clone(); + let actor_ready = ready.clone(); + async move |connection| { + connected_actor( + &executor, + connection, + dialect.as_ref(), + &launch, + &opts, + &commands, + &actor_events, + &actor_ready, + &client, + &stderr_tail, + &io_done, + &session_started, + &pending_deliveries, + ) + .await + } + }) + .await; + + // The connection closure owns the protocol shutdown; this is the hard + // process boundary for both graceful shutdowns and broken transports. A + // startup failure is reported only once the agent is gone, so a caller + // that gives up on the error leaves no process behind. + let _ = child.kill(); + let _ = child.status().await; + + let connection_result = match connection_result { + Ok(Exit::StartFailed(err)) => { + let _ = ready.send(Err(err)).await; + return; + } + Ok(Exit::Closed(reason)) => Ok(reason), + Err(err) => Err(err), + }; + if !session_started.load(Ordering::Acquire) { + if let Err(err) = connection_result { + let message = format!("ACP transport error: {}", describe(&err)); + let message = stderr_tail.append_to(message, "\n"); + let _ = ready.send(Err(AgentError::Protocol(message))).await; + } + return; + } + + let close_reason = match connection_result { + Ok(reason) => reason, + Err(err) => Some(format!("ACP transport error: {}", describe(&err))), + }; + let close_reason = close_reason.map(|reason| stderr_tail.append_to(reason, "\nstderr:\n")); + let _ = events + .send(AgentEvent::SessionClosed { + reason: close_reason, + }) + .await; +} + +enum Exit { + StartFailed(AgentError), + /// The session ended, with a reason unless it was asked to. + Closed(Option), +} + +#[allow(clippy::too_many_arguments)] +async fn connected_actor( + executor: &Rc>, + connection: Connection, + dialect: &D, + launch: &Launch, + opts: &SessionOptions, + commands: &Receiver, + events: &Sender, + ready: &Sender>, + client: &Client, + stderr_tail: &crate::process::StderrTail, + io_done: &Receiver, + session_started: &AtomicBool, + pending_deliveries: &Arc>>, +) -> Result { + enum Startup { + Handshake(Result<(Established, bool), AgentError>), + Io(String), + } + + let state = &client.state; + let startup = future::or( + async { + Startup::Handshake(handshake(&connection, dialect, launch, opts, state, events).await) + }, + async { + Startup::Io( + io_done + .recv() + .await + .unwrap_or_else(|_| "the ACP agent closed its stdio".to_string()), + ) + }, + ) + .await; + let (established, can_close) = match startup { + Startup::Handshake(Ok(established)) => established, + Startup::Handshake(Err(err)) => { + let err = match err { + AgentError::Protocol(message) => { + AgentError::Protocol(stderr_tail.append_to(message, "\n")) + } + other => other, + }; + return Ok(Exit::StartFailed(err)); + } + Startup::Io(reason) => { + let message = stderr_tail.append_to(reason, "\n"); + return Ok(Exit::StartFailed(AgentError::Protocol(message))); + } + }; + let session_id = established.session_id; + let model = { + let mut state = state.lock_recover(); + state.session_id = Some(session_id.clone()); + state.options.current_model() + }; + + let _ = events + .send(AgentEvent::SessionStarted { + provider_session_id: session_id.0.to_string(), + resume: established.resume, + model, + }) + .await; + emit_provider_options(state, events, false).await; + if ready.send(Ok(())).await.is_err() { + return Ok(Exit::Closed(None)); + } + session_started.store(true, Ordering::Release); + + let session = client.session(connection.clone()); + let (turn_tx, turn_done) = smol::channel::unbounded::(); + let mut turn_seq: u64 = 0; + + let close_reason = loop { + enum Input { + Command(Result), + Turn(TurnOutcome), + Io(String), + } + let input = future::or( + future::or(async { Input::Command(commands.recv().await) }, async { + match turn_done.recv().await { + Ok(outcome) => Input::Turn(outcome), + // This loop holds the sender, so the channel cannot close. + Err(_) => future::pending().await, + } + }), + async { + match io_done.recv().await { + Ok(reason) => Input::Io(reason), + Err(_) => future::pending().await, + } + }, + ) + .await; + + match input { + Input::Command(Ok(SessionCommand::Shutdown)) | Input::Command(Err(_)) => break None, + Input::Command(Ok(command)) => { + handle_command( + command, + dialect, + executor, + &session, + &session_id, + &turn_tx, + &mut turn_seq, + pending_deliveries, + ) + .await; + } + Input::Turn(outcome) => finish_turn(dialect, state, events, outcome).await, + Input::Io(reason) => break Some(reason), + } + }; + + // Only a live transport can acknowledge a graceful close. A dead stdout + // has already ended the session and must not leave us awaiting a response. + if close_reason.is_none() { + // An agent blocked on one of our answers may not settle the close. + cancel_pending(state, events).await; + if can_close { + let _ = connection + .send_request(acp::CloseSessionRequest::new(session_id)) + .block_task() + .await; + } + } + + Ok(Exit::Closed(close_reason)) +} + +struct TurnOutcome { + turn_id: String, + result: Result, +} + +/// `initialize`, then the dialect's session establishment. Also reports +/// whether the agent can close sessions. +async fn handshake( + connection: &Connection, + dialect: &D, + launch: &Launch, + opts: &SessionOptions, + state: &Arc>, + events: &Sender, +) -> Result<(Established, bool), AgentError> { + let name = dialect.name(); + let init = initialize(connection, name, launch, dialect.client_services()).await?; + let setup = Setup { + connection, + opts, + init: &init, + name, + state, + events, + }; + let established = dialect.establish(&setup).await?; + let can_close = init.agent_capabilities.session_capabilities.close.is_some(); + Ok((established, can_close)) +} + +async fn initialize( + connection: &Connection, + name: &str, + launch: &Launch, + client_services: bool, +) -> Result { + let mut client_capabilities = acp::ClientCapabilities::new() + .fs(acp::FileSystemCapabilities::new() + .read_text_file(client_services) + .write_text_file(client_services)) + .terminal(client_services); + client_capabilities.meta = launch.client_meta.clone(); + // On a leash: an agent that starts but never answers `initialize` (cline + // 3.0.39 does exactly this) would otherwise hang session startup forever, + // with the UI stuck on "Starting…". + let init = future::or( + async { + Some( + connection + .send_request( + acp::InitializeRequest::new(ProtocolVersion::LATEST) + .client_capabilities(client_capabilities) + .client_info( + acp::Implementation::new("tcode", env!("CARGO_PKG_VERSION")) + .title("Tcode"), + ), + ) + .block_task() + .await, + ) + }, + async { + smol::Timer::after(INITIALIZE_TIMEOUT).await; + None + }, + ) + .await; + let init = match init { + Some(Ok(init)) => init, + Some(Err(err)) => { + return Err(AgentError::Protocol(format!( + "`{name}` failed to initialize: {}", + describe(&err) + ))); + } + None => { + return Err(AgentError::Protocol(format!( + "`{name}` did not answer `initialize` within {}s — it may not speak ACP over stdio (check its launch arguments)", + INITIALIZE_TIMEOUT.as_secs() + ))); + } + }; + Ok(init) +} + +#[allow(clippy::too_many_arguments)] +async fn handle_command( + command: SessionCommand, + dialect: &D, + executor: &Rc>, + session: &Session, + session_id: &acp::SessionId, + turn_tx: &Sender, + turn_seq: &mut u64, + pending_deliveries: &Arc>>, +) { + let connection = &session.connection; + let state = &session.state; + let events = &session.events; + match command { + SessionCommand::Steer { + request_id, + text, + attachments, + } => dialect.steer(session, request_id, text, attachments).await, + SessionCommand::SendTurn { + delivery_id, + text, + attachments, + .. + } => { + *turn_seq += 1; + let id = format!("turn-{turn_seq}"); + state.lock_recover().turn = Some(id.clone()); + + let request = + acp::PromptRequest::new(session_id.clone(), prompt_blocks(&text, &attachments)); + // The observed stdio writer consumes this id when the complete + // `session/prompt` JSON-RPC line reaches the child. + pending_deliveries.lock_recover().push_back(delivery_id); + let request = connection.send_request(request); + let _ = events + .send(AgentEvent::TurnStarted { + turn_id: id.clone(), + }) + .await; + let turn_tx = turn_tx.clone(); + executor + .spawn(async move { + let result = request.block_task().await; + let _ = turn_tx + .send(TurnOutcome { + turn_id: id, + result, + }) + .await; + }) + .detach(); + } + SessionCommand::Interrupt => { + // The open turn may be one the agent started itself. + if state.lock_recover().turn.is_none() { + return; + } + // Every in-flight permission request must be answered with + // `cancelled` first: the protocol requires it, and the agent will + // not settle the turn otherwise. + cancel_pending(state, events).await; + let _ = connection.send_notification(acp::CancelNotification::new(session_id.clone())); + // The agent still owes us the `session/prompt` response carrying + // `stopReason: cancelled`; the turn completes when it lands. + } + SessionCommand::RespondApproval { + request_id, + decision, + } => { + let approval = state.lock_recover().approvals.remove(&request_id); + let Some((responder, options)) = approval else { + log::warn!("acp: no pending approval {request_id}"); + return; + }; + let outcome = match approval_outcome(&decision, &options) { + Some(outcome) => outcome, + None => { + let _ = events + .send(AgentEvent::Warning { + message: + "the agent offered no matching permission option; cancelling instead" + .into(), + }) + .await; + acp::RequestPermissionOutcome::Cancelled + } + }; + let _ = responder.send(outcome).await; + let _ = events + .send(AgentEvent::ApprovalResolved { + request_id, + decision, + }) + .await; + } + SessionCommand::SetOption { id, value } => { + let Some(origin) = state.lock_recover().options.origin(&id) else { + log::warn!("acp: unknown option id `{id}`"); + return; + }; + match set_option(connection, session_id, &origin, &value).await { + Ok(config_options) => { + { + let mut state = state.lock_recover(); + if let Some(options) = config_options.as_deref() { + state.options.ingest(None, Some(options)); + } + if origin == OptionOrigin::Mode { + if let Some(mode) = value.as_str() { + state.select_mode(acp::SessionModeId::new(mode)); + } + } else { + state.options.select(&id, value); + } + } + emit_provider_options(state, events, false).await; + } + Err(err) => { + let _ = events + .send(AgentEvent::Warning { + message: format!("could not apply `{id}`: {}", describe(&err)), + }) + .await; + } + } + } + SessionCommand::RespondUserInput { + request_id, + answers, + } => { + let Some(answer) = state.lock_recover().inputs.remove(&request_id) else { + log::warn!("acp: no pending user input {request_id}"); + return; + }; + let _ = answer.send(answers.clone()).await; + let _ = events + .send(AgentEvent::UserInputResolved { + request_id, + answers, + }) + .await; + } + SessionCommand::SetApprovalMode(mode) => dialect.set_approval_mode(session, mode).await, + SessionCommand::SetInteractionMode(mode) => { + apply_interaction_mode(mode, state, events, connection, session_id).await; + } + SessionCommand::Rewind { + checkpoint_id, + mode, + } => { + let _ = events + .send(AgentEvent::RewindFailed { + checkpoint_id, + mode, + error: "this ACP agent does not advertise a native rewind operation".into(), + }) + .await; + } + SessionCommand::Shutdown => unreachable!("handled by the caller"), + } +} + +/// Settle every request waiting on the user: permissions answer `cancelled` +/// and questions resolve with no answers. +async fn cancel_pending(state: &Arc>, events: &Sender) { + let (approvals, inputs): (Vec<_>, Vec<_>) = { + let mut state = state.lock_recover(); + ( + state + .approvals + .drain() + .map(|(_, (responder, _))| responder) + .collect(), + state + .inputs + .drain() + .map(|(request_id, _)| request_id) + .collect(), + ) + }; + for responder in approvals { + let _ = responder + .send(acp::RequestPermissionOutcome::Cancelled) + .await; + } + for request_id in inputs { + let _ = events + .send(AgentEvent::UserInputResolved { + request_id, + answers: Map::new(), + }) + .await; + } +} + +/// Complete a sent turn with its `session/prompt` result, unless the dialect +/// already completed it from the agent's own signal ([`State::end_turn`]). +async fn finish_turn( + dialect: &D, + state: &Arc>, + events: &Sender, + outcome: TurnOutcome, +) { + let completion = { + let mut state = state.lock_recover(); + if state.turn.as_deref() != Some(outcome.turn_id.as_str()) { + return; + } + let end = dialect.turn_end(&mut state, outcome.result); + state.complete_turn(&outcome.turn_id, end) + }; + for event in completion { + let _ = events.send(event).await; + } +} + +/// The status and message of a `session/prompt` result. A `-32800 +/// request_cancelled` error is an agent aborting the prompt outright instead +/// of returning `stopReason: cancelled`. +pub(crate) fn prompt_status( + result: &Result, +) -> (TurnStatus, Option) { + match result { + Ok(response) => stop_reason_status(response.stop_reason), + Err(err) if i32::from(err.code) == -32800 => (TurnStatus::Interrupted, None), + Err(err) => (TurnStatus::Failed, Some(describe(err))), + } +} + +/// Reports an agent closing (or breaking) stdout while still handing the bytes +/// to the SDK transport. The SDK's `ByteStreams` treats a clean EOF as a +/// completed input stream, which does not end the command loop; observed here, +/// it closes the session at once. +struct ObservedReader { + inner: smol::process::ChildStdout, + done: Sender, +} + +impl ObservedReader { + fn new(inner: smol::process::ChildStdout, done: Sender) -> Self { + Self { inner, done } + } +} + +impl AsyncRead for ObservedReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + match Pin::new(&mut self.inner).poll_read(cx, buf) { + Poll::Ready(Ok(0)) => { + let _ = self + .done + .try_send("the ACP agent closed its stdio".to_string()); + Poll::Ready(Ok(0)) + } + Poll::Ready(Err(err)) => { + let _ = self.done.try_send(format!("ACP transport error: {err}")); + Poll::Ready(Err(err)) + } + other => other, + } + } +} + +/// Reports a turn only after the SDK has written its complete JSON-RPC prompt +/// line to the child. `ConnectionTo::send_request` merely queues internally and +/// does not expose a synchronous enqueue failure, so it is not a delivery +/// boundary on its own. +struct ObservedWriter { + inner: W, + line: Vec, + pending_deliveries: Arc>>, + events: Sender, +} + +impl ObservedWriter { + fn new( + inner: W, + pending_deliveries: Arc>>, + events: Sender, + ) -> Self { + Self { + inner, + line: Vec::new(), + pending_deliveries, + events, + } + } + + fn finish_line(&mut self) { + let is_prompt = serde_json::from_slice::(&self.line) + .ok() + .is_some_and(|message| { + message.get("method").and_then(Value::as_str) == Some("session/prompt") + }); + self.line.clear(); + if !is_prompt { + return; + } + if let Some(delivery_id) = self.pending_deliveries.lock_recover().pop_front() { + let _ = self + .events + .try_send(AgentEvent::TurnAccepted { delivery_id }); + } + } +} + +impl AsyncWrite for ObservedWriter { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let written = match Pin::new(&mut self.inner).poll_write(cx, buf) { + Poll::Ready(Ok(written)) => written, + other => return other, + }; + for byte in &buf[..written] { + if *byte == b'\n' { + self.finish_line(); + } else { + self.line.push(*byte); + } + } + Poll::Ready(Ok(written)) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_close(cx) + } +} + +fn acp_plan_mode(modes: &acp::SessionModeState) -> Option { + modes + .available_modes + .iter() + .find(|mode| mode.id.0.eq_ignore_ascii_case("plan")) + .map(|mode| mode.id.clone()) +} + +fn first_non_plan_mode(modes: &acp::SessionModeState) -> Option { + let plan = acp_plan_mode(modes); + modes + .available_modes + .iter() + .find(|mode| Some(&mode.id) != plan.as_ref()) + .map(|mode| mode.id.clone()) +} + +/// ACP's `auth_required` error. +pub(crate) fn is_auth_required(err: &acp::Error) -> bool { + i32::from(err.code) == -32000 +} + +pub(crate) fn describe(err: &acp::Error) -> String { + match &err.data { + Some(data) => format!("{} ({data})", err.message), + None => err.message.clone(), + } +} + +/// The `mcpServers` array for `session/new`, gated on `mcpCapabilities.http`. +fn mcp_servers( + registrations: &[&McpRegistration], + caps: &acp::AgentCapabilities, +) -> Vec { + if !caps.mcp_capabilities.http { + return Vec::new(); + } + registrations + .iter() + .map(|mcp| { + acp::McpServer::Http( + acp::McpServerHttp::new(mcp.name.clone(), mcp.url.clone()).headers(vec![ + acp::HttpHeader::new("Authorization", format!("Bearer {}", mcp.bearer_token)), + ]), + ) + }) + .collect() +} + +async fn set_option( + connection: &Connection, + session_id: &acp::SessionId, + origin: &OptionOrigin, + value: &Value, +) -> Result>, acp::Error> { + match origin { + OptionOrigin::Mode => { + let Some(mode) = value.as_str() else { + return Err(acp::Error::invalid_params()); + }; + connection + .send_request(acp::SetSessionModeRequest::new( + session_id.clone(), + acp::SessionModeId::new(mode), + )) + .block_task() + .await?; + Ok(None) + } + OptionOrigin::Config(config_id) => { + let value = match value { + Value::Bool(value) => acp::SessionConfigOptionValue::boolean(*value), + Value::String(value) => acp::SessionConfigOptionValue::value_id( + acp::SessionConfigValueId::new(value.as_str()), + ), + _ => return Err(acp::Error::invalid_params()), + }; + let response = connection + .send_request(acp::SetSessionConfigOptionRequest::new( + session_id.clone(), + config_id.clone(), + value, + )) + .block_task() + .await?; + Ok(Some(response.config_options)) + } + } +} + +async fn apply_interaction_mode( + mode: InteractionMode, + state: &Arc>, + events: &Sender, + connection: &Connection, + session_id: &acp::SessionId, +) { + let target = interaction_mode_target(mode, &state.lock_recover()); + let Some(target) = target else { + let _ = events.send(missing_mode_warning(mode)).await; + // Republish the actual selection so the runtime rolls back its + // optimistic Build/Plan toggle instead of leaving a lying chip. + emit_provider_options(state, events, true).await; + return; + }; + + if state + .lock_recover() + .modes + .as_ref() + .is_some_and(|modes| modes.current_mode_id == target) + { + return; + } + + match connection + .send_request(acp::SetSessionModeRequest::new( + session_id.clone(), + target.clone(), + )) + .block_task() + .await + { + Ok(_) => { + state.lock_recover().select_mode(target); + emit_provider_options(state, events, false).await; + } + Err(err) => { + let _ = events + .send(AgentEvent::Warning { + message: format!("could not switch this agent's mode: {}", describe(&err)), + }) + .await; + emit_provider_options(state, events, true).await; + } + } +} + +fn interaction_mode_target(mode: InteractionMode, state: &State) -> Option { + match mode { + InteractionMode::Plan => state.modes.as_ref().and_then(acp_plan_mode), + InteractionMode::Build => state.build_mode(), + } +} + +fn missing_mode_warning(mode: InteractionMode) -> AgentEvent { + let mode_name = match mode { + InteractionMode::Plan => "Plan", + InteractionMode::Build => "Build", + }; + AgentEvent::Warning { + message: format!( + "This agent does not advertise a {mode_name} mode; the mode switch was not applied." + ), + } +} + +/// Map the approval UI's four fixed decisions onto the agent's own permission +/// options, and pass an offered `Option(id)` through. +/// +/// An agent's `allow_always`/`reject_always` options record a rule in its own +/// persistent allowlist, beyond this session. Only the user's explicit choice of +/// such an option may select one; every fixed decision, including the +/// session-scoped `ApproveForSession`, answers once. +fn approval_outcome( + decision: &ApprovalDecision, + options: &[ApprovalOption], +) -> Option { + let offered = |kind: ApprovalOptionKind| { + options + .iter() + .find(|option| option.kind == kind) + .map(|option| option.id.clone()) + }; + let selected = match decision { + ApprovalDecision::Cancel => return Some(acp::RequestPermissionOutcome::Cancelled), + ApprovalDecision::Option(id) => options + .iter() + .any(|option| &option.id == id) + .then(|| id.clone()), + ApprovalDecision::Approve | ApprovalDecision::ApproveForSession => { + offered(ApprovalOptionKind::AllowOnce) + } + ApprovalDecision::Deny => offered(ApprovalOptionKind::RejectOnce), + }?; + Some(acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome::new(acp::PermissionOptionId::new(selected)), + )) +} + +/// The option an automatic approval selects. Persistent options would record a +/// rule beyond this session that no one chose. +fn allow_once(options: &[acp::PermissionOption]) -> Option { + options + .iter() + .find(|option| option.kind == acp::PermissionOptionKind::AllowOnce) + .map(|option| option.option_id.clone()) +} + +/// `stopReason` → canonical turn status, plus the message to surface (if any). +pub(crate) fn stop_reason_status(reason: acp::StopReason) -> (TurnStatus, Option) { + match reason { + acp::StopReason::EndTurn => (TurnStatus::Completed, None), + acp::StopReason::Cancelled => (TurnStatus::Interrupted, None), + acp::StopReason::Refusal => ( + TurnStatus::Failed, + Some("The agent refused to continue this turn.".into()), + ), + acp::StopReason::MaxTokens => ( + TurnStatus::Failed, + Some("The agent stopped: token limit reached.".into()), + ), + acp::StopReason::MaxTurnRequests => ( + TurnStatus::Failed, + Some("The agent stopped: too many model requests in one turn.".into()), + ), + other => ( + TurnStatus::Failed, + Some(format!("The agent stopped: {other:?}")), + ), + } +} + +fn prompt_blocks(text: &str, attachments: &[Attachment]) -> Vec { + let mut blocks = Vec::with_capacity(1 + attachments.len()); + blocks.push(acp::ContentBlock::Text(acp::TextContent::new( + text.to_string(), + ))); + for attachment in attachments { + blocks.push(acp::ContentBlock::Image(acp::ImageContent::new( + attachment.data_base64.clone(), + attachment.media_type.clone(), + ))); + } + blocks +} + +async fn emit_provider_options( + state: &Arc>, + events: &Sender, + emit_empty: bool, +) { + let event = state.lock_recover().provider_options(); + if matches!( + &event, + AgentEvent::ProviderOptions { descriptors, .. } if descriptors.is_empty() + ) && !emit_empty + { + return; + } + let _ = events.send(event).await; +} + +/// Where a canonical option id came from, so `SetOption` routes to the right +/// ACP method. + +#[derive(Debug, Clone, PartialEq)] +enum OptionOrigin { + Mode, + Config(acp::SessionConfigId), +} + +/// The agent's self-described modes and config options, mapped onto our +/// [`OptionDescriptor`]s (which the composer's traits picker already renders). +#[derive(Default)] +struct OptionRegistry { + records: Vec, +} + +struct OptionRecord { + descriptor: OptionDescriptor, + selection: OptionSelection, + origin: OptionOrigin, +} + +impl OptionRegistry { + fn ingest( + &mut self, + modes: Option<&acp::SessionModeState>, + config: Option<&[acp::SessionConfigOption]>, + ) { + if let Some(modes) = modes { + self.upsert( + OptionDescriptor::Select { + id: MODE_OPTION_ID.to_string(), + label: "Mode".to_string(), + options: modes + .available_modes + .iter() + .map(|mode| SelectOption { + value: mode.id.0.to_string(), + label: mode.name.clone(), + description: mode.description.clone(), + }) + .collect(), + default_value: Some(modes.current_mode_id.0.to_string()), + }, + OptionOrigin::Mode, + Value::String(modes.current_mode_id.0.to_string()), + ); + } + // Each list is the agent's complete set: an option it no longer + // offers (a parameter of the previous model) goes. + if let Some(config) = config { + self.records.retain(|record| match &record.origin { + OptionOrigin::Config(id) => config.iter().any(|option| &option.id == id), + OptionOrigin::Mode => true, + }); + } + for option in config.unwrap_or_default() { + // The mode and model categories keep tcode's canonical ids (the + // runtime reads `acp:mode`); every option, whatever its category, + // is set through `session/set_config_option`. + let id = match option.category.as_ref() { + Some(acp::SessionConfigOptionCategory::Mode) => MODE_OPTION_ID.to_string(), + Some(acp::SessionConfigOptionCategory::Model) => MODEL_OPTION_ID.to_string(), + _ => format!("{CONFIG_OPTION_PREFIX}{}", option.id.0), + }; + match &option.kind { + acp::SessionConfigKind::Select(select) => { + let options = match &select.options { + acp::SessionConfigSelectOptions::Ungrouped(flat) => flat + .iter() + .map(|option| SelectOption { + value: option.value.0.to_string(), + label: option.name.clone(), + description: option.description.clone(), + }) + .collect(), + acp::SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| { + group.options.iter().map(move |option| SelectOption { + value: option.value.0.to_string(), + label: format!("{} · {}", group.name, option.name), + description: option.description.clone(), + }) + }) + .collect(), + _ => Vec::new(), + }; + self.upsert( + OptionDescriptor::Select { + id, + label: option.name.clone(), + options, + default_value: Some(select.current_value.0.to_string()), + }, + OptionOrigin::Config(option.id.clone()), + Value::String(select.current_value.0.to_string()), + ); + } + acp::SessionConfigKind::Boolean(boolean) => self.upsert( + OptionDescriptor::Boolean { + id, + label: option.name.clone(), + default_value: boolean.current_value, + }, + OptionOrigin::Config(option.id.clone()), + Value::Bool(boolean.current_value), + ), + _ => log::warn!("acp: unsupported config-option kind for `{}`", option.id.0), + } + } + } + + fn upsert(&mut self, descriptor: OptionDescriptor, origin: OptionOrigin, value: Value) { + let id = descriptor_id(&descriptor).to_string(); + match self + .records + .iter_mut() + .find(|record| record.selection.id == id) + { + Some(record) => { + record.descriptor = descriptor; + record.selection.value = value; + record.origin = origin; + } + None => self.records.push(OptionRecord { + descriptor, + selection: OptionSelection { id, value }, + origin, + }), + } + } + + fn select(&mut self, id: &str, value: Value) { + if let Some(record) = self + .records + .iter_mut() + .find(|record| record.selection.id == id) + { + record.selection.value = value; + } + } + + fn origin(&self, id: &str) -> Option { + self.records + .iter() + .find(|record| record.selection.id == id) + .map(|record| record.origin.clone()) + } + + fn current_model(&self) -> Option { + self.records + .iter() + .find(|record| record.selection.id == MODEL_OPTION_ID) + .and_then(|record| record.selection.value.as_str().map(str::to_string)) + } +} + +fn descriptor_id(descriptor: &OptionDescriptor) -> &str { + match descriptor { + OptionDescriptor::Select { id, .. } | OptionDescriptor::Boolean { id, .. } => id, + } +} + +/// A tool call as last known. `tool_call_update` is a partial patch, so the +/// merged state lives here and every update re-renders the whole item. +#[derive(Debug, Clone, Default)] +struct ToolState { + title: String, + kind: acp::ToolKind, + status: acp::ToolCallStatus, + content: Vec, + locations: Vec, + raw_input: Option, + raw_output: Option, + announced: bool, +} + +/// The text block currently streaming (assistant prose or thinking). +struct TextStream { + id: String, + kind: DeltaKind, + text: String, +} + +/// One live session's protocol state: the standard mapping's tool, text and +/// option state, the agent's terminals and the requests waiting on the user. +pub(crate) struct State { + cwd: PathBuf, + session_id: Option, + turn: Option, + /// True while `session/load` answers, which may replay history first. + loading: bool, + tools: HashMap, + text: Option, + approvals: HashMap, Vec)>, + approval_seq: u64, + inputs: HashMap>>, + input_seq: u64, + text_seq: u64, + terminal_seq: u64, + terminals: HashMap>, + usage: Option, + options: OptionRegistry, + /// [`Dialect::owned_config_options`]. + owned_options: &'static [&'static str], + /// A turn the agent started while another was still open. + next_turn: Option, + modes: Option, + previous_non_plan_mode: Option, +} + +impl State { + pub(crate) fn new(cwd: PathBuf) -> Self { + Self { + cwd, + session_id: None, + turn: None, + loading: false, + tools: HashMap::new(), + text: None, + approvals: HashMap::new(), + approval_seq: 0, + inputs: HashMap::new(), + input_seq: 0, + text_seq: 0, + terminal_seq: 0, + terminals: HashMap::new(), + usage: None, + options: OptionRegistry::default(), + owned_options: &[], + next_turn: None, + modes: None, + previous_non_plan_mode: None, + } + } + + /// Whether `session/load` is still answering. + pub(crate) fn loading(&self) -> bool { + self.loading + } + + /// The running turn's id. + pub(crate) fn turn(&self) -> Option<&str> { + self.turn.as_deref() + } + + /// The latest `usage_update` context figure. + pub(crate) fn usage(&self) -> Option { + self.usage + } + + /// Open a turn the agent started on its own, which no `session/prompt` + /// answers. While another turn is still open it opens as soon as that one + /// completes. + pub(crate) fn begin_agent_turn(&mut self, turn_id: &str) -> Vec { + if self.turn.is_some() { + self.next_turn = Some(turn_id.to_string()); + return Vec::new(); + } + self.turn = Some(turn_id.to_string()); + vec![AgentEvent::TurnStarted { + turn_id: turn_id.to_string(), + }] + } + + /// Complete `turn_id` from the agent's own signal if it is still the open + /// turn. A sent turn completed this way ignores its later `session/prompt` + /// result. A turn the client sent while an agent-started one ran has taken + /// its place and completes as sent turns do; only the ended turn's streamed + /// text is closed. + pub(crate) fn end_turn(&mut self, turn_id: &str, end: TurnEnd) -> Vec { + if self.next_turn.as_deref() == Some(turn_id) { + self.next_turn = None; + } + if self.turn.as_deref() != Some(turn_id) { + return self.flush_text(); + } + self.complete_turn(turn_id, end) + } + + /// Close the open turn: its streaming text, the end's message and its + /// completion, then open the agent-started turn waiting behind it. + fn complete_turn(&mut self, turn_id: &str, end: TurnEnd) -> Vec { + let mut events = self.flush_text(); + self.turn = None; + events.extend(end.message.map(|message| AgentEvent::Error { + message, + fatal: false, + })); + events.push(AgentEvent::TurnCompleted { + turn_id: turn_id.to_string(), + status: end.status, + usage: end.usage, + }); + if let Some(next) = self.next_turn.take() { + events.extend(self.begin_agent_turn(&next)); + } + events + } + + fn ingest_modes(&mut self, modes: Option<&acp::SessionModeState>) { + let Some(modes) = modes else { + return; + }; + self.modes = Some(modes.clone()); + self.select_mode(modes.current_mode_id.clone()); + self.options.ingest(Some(modes), None); + } + + fn select_mode(&mut self, mode: acp::SessionModeId) { + let is_plan = self + .modes + .as_ref() + .and_then(acp_plan_mode) + .is_some_and(|plan| plan == mode); + if !is_plan { + self.previous_non_plan_mode = Some(mode.clone()); + } + if let Some(modes) = self.modes.as_mut() { + modes.current_mode_id = mode.clone(); + } + self.options + .select(MODE_OPTION_ID, Value::String(mode.0.to_string())); + } + + fn build_mode(&self) -> Option { + let modes = self.modes.as_ref()?; + // ACP exposes no distinct default mode. Remember the last non-plan + // selection from before Plan; if the session began in Plan, fall back + // to the first advertised non-plan mode (the agent's ordering). + self.previous_non_plan_mode + .clone() + .or_else(|| first_non_plan_mode(modes)) + } + + fn provider_options(&self) -> AgentEvent { + let surfaced = || { + self.options.records.iter().filter(|record| { + !matches!(&record.origin, OptionOrigin::Config(id) + if self.owned_options.contains(&id.0.as_ref())) + }) + }; + AgentEvent::ProviderOptions { + descriptors: surfaced().map(|record| record.descriptor.clone()).collect(), + selections: surfaced().map(|record| record.selection.clone()).collect(), + } + } + + /// Close the open text block, emitting its final `ItemCompleted`. + pub(crate) fn flush_text(&mut self) -> Vec { + let Some(stream) = self.text.take() else { + return Vec::new(); + }; + if stream.text.is_empty() { + return Vec::new(); + } + let content = match stream.kind { + DeltaKind::ReasoningText => ItemContent::Reasoning { text: stream.text }, + _ => ItemContent::AssistantMessage { text: stream.text }, + }; + vec![AgentEvent::ItemCompleted(ThreadItem { + id: stream.id, + parent_item_id: None, + content, + })] + } + + /// Append a streaming chunk, opening a new item when the block changes. + fn push_text(&mut self, kind: DeltaKind, text: String) -> Vec { + let mut events = Vec::new(); + let same_block = self.text.as_ref().is_some_and(|stream| stream.kind == kind); + if !same_block { + events.extend(self.flush_text()); + self.text_seq += 1; + let prefix = match kind { + DeltaKind::ReasoningText => "thought", + _ => "msg", + }; + self.text = Some(TextStream { + id: format!("{prefix}-{}", self.text_seq), + kind, + text: String::new(), + }); + } + let stream = self.text.as_mut().expect("stream just opened"); + stream.text.push_str(&text); + events.push(AgentEvent::Delta { + item_id: stream.id.clone(), + kind, + text, + }); + events + } + + /// Map one `session/update` onto canonical events, merging the tool-call and + /// option state along the way. + pub(crate) fn apply_update(&mut self, update: acp::SessionUpdate) -> Vec { + match update { + // The app synthesizes the canonical user message at send time; + // rendering the agent's echo of it would double it. + acp::SessionUpdate::UserMessageChunk(_) => Vec::new(), + acp::SessionUpdate::AgentMessageChunk(chunk) => { + let text = content_text(&chunk.content); + if text.is_empty() { + return Vec::new(); + } + self.push_text(DeltaKind::AssistantText, text) + } + acp::SessionUpdate::AgentThoughtChunk(chunk) => { + let text = content_text(&chunk.content); + if text.is_empty() { + return Vec::new(); + } + self.push_text(DeltaKind::ReasoningText, text) + } + acp::SessionUpdate::ToolCall(call) => { + let mut events = self.flush_text(); + let id = call.tool_call_id.0.to_string(); + let tool = ToolState { + title: call.title, + kind: call.kind, + status: call.status, + content: call.content, + locations: call.locations, + raw_input: call.raw_input, + raw_output: call.raw_output, + announced: true, + }; + let item = self.tool_item(&id, &tool); + let existed = self + .tools + .insert(id, tool) + .is_some_and(|previous| previous.announced); + events.push(if existed { + AgentEvent::ItemUpdated(item) + } else { + AgentEvent::ItemStarted(item) + }); + events + } + acp::SessionUpdate::ToolCallUpdate(update) => { + let mut events = self.flush_text(); + let id = update.tool_call_id.0.to_string(); + let entry = self.tools.entry(id.clone()).or_default(); + let fields = update.fields; + if let Some(title) = fields.title { + entry.title = title; + } + if let Some(kind) = fields.kind { + entry.kind = kind; + } + if let Some(status) = fields.status { + entry.status = status; + } + // `content` and `locations` are whole-array replacements. + if let Some(content) = fields.content { + entry.content = content; + } + if let Some(locations) = fields.locations { + entry.locations = locations; + } + if let Some(raw_input) = fields.raw_input { + entry.raw_input = Some(raw_input); + } + if let Some(raw_output) = fields.raw_output { + entry.raw_output = Some(raw_output); + } + let announced = std::mem::replace(&mut entry.announced, true); + let status = entry.status; + let tool = entry.clone(); + let item = self.tool_item(&id, &tool); + events.push(match (announced, status) { + (false, _) => AgentEvent::ItemStarted(item), + (true, acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed) => { + AgentEvent::ItemCompleted(item) + } + _ => AgentEvent::ItemUpdated(item), + }); + events + } + acp::SessionUpdate::Plan(plan) => vec![AgentEvent::PlanUpdated { + turn_id: self.turn().map(str::to_owned), + explanation: None, + steps: plan.entries.iter().map(plan_step).collect(), + }], + acp::SessionUpdate::AvailableCommandsUpdate(update) => { + vec![AgentEvent::ProviderCommands { + commands: update + .available_commands + .iter() + .map(|command| ProviderCommand { + name: command.name.clone(), + description: Some(command.description.clone()), + kind: ProviderCommandKind::Command, + }) + .collect(), + }] + } + acp::SessionUpdate::CurrentModeUpdate(update) => { + self.select_mode(update.current_mode_id.clone()); + vec![self.provider_options()] + } + acp::SessionUpdate::ConfigOptionUpdate(update) => { + self.options.ingest(None, Some(&update.config_options)); + vec![self.provider_options()] + } + acp::SessionUpdate::UsageUpdate(usage) => { + let usage = TokenUsage { + freshness: crate::ContextFreshness::Current, + used_tokens: Some(usage.used), + context_window: Some(usage.size), + cost_usd: usage + .cost + .as_ref() + .filter(|cost| cost.currency.eq_ignore_ascii_case("USD")) + .map(|cost| cost.amount), + ..Default::default() + }; + self.usage = Some(usage); + vec![AgentEvent::TokenUsage(usage)] + } + // Session titles are ours (the sidebar names sessions from the first + // user message), and unknown variants degrade to nothing. + _ => Vec::new(), + } + } + + /// The canonical item for a tool call, keyed by [`acp::ToolKind`]. + fn tool_item(&self, id: &str, tool: &ToolState) -> ThreadItem { + let status = map_status(tool.status); + let content = match tool.kind { + acp::ToolKind::Execute => ItemContent::CommandExecution { + command: command_of(tool), + output: self.tool_output(tool), + exit_code: self.exit_code_of(tool), + status, + }, + acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move => { + let changes = file_changes(tool); + if changes.is_empty() { + tool_call_content(tool, status, self.tool_output(tool)) + } else { + ItemContent::FileChange { changes, status } + } + } + acp::ToolKind::Think => { + let text = self.tool_output(tool); + ItemContent::Reasoning { + text: if text.is_empty() { + tool.title.clone() + } else { + text + }, + } + } + _ => tool_call_content(tool, status, self.tool_output(tool)), + }; + ThreadItem { + id: id.into(), + parent_item_id: None, + content, + } + } + + /// Everything the tool produced, as display text: its content blocks plus + /// the live output of any terminal it embedded. + fn tool_output(&self, tool: &ToolState) -> String { + let mut parts: Vec = Vec::new(); + for content in &tool.content { + match content { + acp::ToolCallContent::Content(block) => { + let text = content_text(&block.content); + if !text.is_empty() { + parts.push(text); + } + } + acp::ToolCallContent::Terminal(terminal) => { + if let Some(terminal) = self.terminals.get(terminal.terminal_id.0.as_ref()) { + let output = terminal.output.lock_recover(); + if !output.is_empty() { + parts.push(output.clone()); + } + } + } + // Diffs render as FileChange, not as text. + _ => {} + } + } + if parts.is_empty() + && let Some(raw) = &tool.raw_output + { + match raw.as_str() { + Some(text) => parts.push(text.to_string()), + None if !raw.is_null() => parts.push(raw.to_string()), + None => {} + } + } + parts.join("\n") + } + + fn exit_code_of(&self, tool: &ToolState) -> Option { + for content in &tool.content { + if let acp::ToolCallContent::Terminal(terminal) = content + && let Some(terminal) = self.terminals.get(terminal.terminal_id.0.as_ref()) + && let Some(status) = terminal.exit.lock_recover().as_ref() + { + return status.exit_code.map(|code| code as i32); + } + } + tool.raw_output + .as_ref() + .and_then(|raw| raw.get("exitCode").or_else(|| raw.get("exit_code"))) + .and_then(Value::as_i64) + .map(|code| code as i32) + } + + /// Reject any path the agent asks for that escapes the session's cwd. + fn resolve_path(&self, path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + self.cwd.join(path) + }; + let normalized = normalize(&absolute); + if !normalized.starts_with(normalize(&self.cwd)) { + return Err(acp::Error::new( + -32602, + format!( + "path `{}` is outside the session working directory", + path.display() + ), + )); + } + Ok(normalized) + } +} + +/// Lexical path normalization (the file may not exist yet, so `canonicalize` is +/// not an option for writes). +fn normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out +} + +/// The shell command behind an `execute` tool call: the underlying tool's own +/// arguments when it exposed them, else the agent's title. +fn command_of(tool: &ToolState) -> String { + tool.raw_input + .as_ref() + .and_then(|input| input.get("command").or_else(|| input.get("cmd"))) + .and_then(|command| match command { + Value::String(command) => Some(command.clone()), + Value::Array(parts) => Some( + parts + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(" "), + ), + _ => None, + }) + .unwrap_or_else(|| tool.title.clone()) +} + +fn tool_call_content(tool: &ToolState, status: ItemStatus, output: String) -> ItemContent { + ItemContent::ToolCall { + name: tool.title.clone(), + input: tool.raw_input.clone().unwrap_or(Value::Null), + output: (!output.is_empty()).then_some(output), + status, + } +} + +/// `diff` content blocks → canonical [`FileChange`]s (with a unified diff, so +/// the diff panel renders them like every other provider's edits). +fn file_changes(tool: &ToolState) -> Vec { + let mut changes: Vec = tool + .content + .iter() + .filter_map(|content| match content { + acp::ToolCallContent::Diff(diff) => { + let path = diff.path.to_string_lossy().into_owned(); + Some(FileChange { + kind: match (tool.kind, diff.old_text.as_deref()) { + (acp::ToolKind::Delete, _) => FileChangeKind::Delete, + (acp::ToolKind::Move, _) => FileChangeKind::Rename, + (_, None | Some("")) => FileChangeKind::Create, + _ => FileChangeKind::Modify, + }, + diff: Some(unified_diff( + &path, + diff.old_text.as_deref().unwrap_or(""), + &diff.new_text, + )), + path, + }) + } + _ => None, + }) + .collect(); + if changes.is_empty() && matches!(tool.kind, acp::ToolKind::Delete | acp::ToolKind::Move) { + // Deletes and renames carry no diff; the locations are all we get. + changes = tool + .locations + .iter() + .map(|location| FileChange { + path: location.path.to_string_lossy().into_owned(), + kind: match tool.kind { + acp::ToolKind::Delete => FileChangeKind::Delete, + _ => FileChangeKind::Rename, + }, + diff: None, + }) + .collect(); + } + changes +} + +/// A whole-file unified diff. ACP hands us before/after text rather than a +/// patch, and the diff panel wants `@@` hunks; a single hunk covering the file +/// is exactly what the panel's full-file path already renders. +fn unified_diff(path: &str, old: &str, new: &str) -> String { + let old_lines: Vec<&str> = old.lines().collect(); + let new_lines: Vec<&str> = new.lines().collect(); + let mut out = String::new(); + if old_lines.is_empty() { + out.push_str("--- /dev/null\n"); + } else { + let _ = writeln!(out, "--- a/{path}"); + } + if new_lines.is_empty() { + out.push_str("+++ /dev/null\n"); + } else { + let _ = writeln!(out, "+++ b/{path}"); + } + let _ = writeln!( + out, + "@@ -{},{} +{},{} @@", + usize::from(!old_lines.is_empty()), + old_lines.len(), + usize::from(!new_lines.is_empty()), + new_lines.len() + ); + // Trim the common prefix/suffix so the (very common) single-line edit does + // not render as a whole-file rewrite. + let prefix = old_lines + .iter() + .zip(new_lines.iter()) + .take_while(|(old, new)| old == new) + .count(); + let suffix = old_lines + .iter() + .rev() + .zip(new_lines.iter().rev()) + .take_while(|(old, new)| old == new) + .count() + .min(old_lines.len() - prefix) + .min(new_lines.len() - prefix); + for line in &old_lines[..prefix] { + let _ = writeln!(out, " {line}"); + } + for line in &old_lines[prefix..old_lines.len() - suffix] { + let _ = writeln!(out, "-{line}"); + } + for line in &new_lines[prefix..new_lines.len() - suffix] { + let _ = writeln!(out, "+{line}"); + } + for line in &old_lines[old_lines.len() - suffix..] { + let _ = writeln!(out, " {line}"); + } + out +} + +fn map_status(status: acp::ToolCallStatus) -> ItemStatus { + match status { + acp::ToolCallStatus::Pending | acp::ToolCallStatus::InProgress => ItemStatus::InProgress, + acp::ToolCallStatus::Completed => ItemStatus::Completed, + acp::ToolCallStatus::Failed => ItemStatus::Failed, + _ => ItemStatus::InProgress, + } +} + +fn plan_step(entry: &acp::PlanEntry) -> PlanStep { + PlanStep { + step: entry.content.clone(), + status: match entry.status { + acp::PlanEntryStatus::InProgress => PlanStepStatus::InProgress, + acp::PlanEntryStatus::Completed => PlanStepStatus::Completed, + _ => PlanStepStatus::Pending, + }, + } +} + +/// Displayable text for a content block (images/audio degrade to a marker). +fn content_text(block: &acp::ContentBlock) -> String { + match block { + acp::ContentBlock::Text(text) => text.text.clone(), + acp::ContentBlock::ResourceLink(link) => link.uri.clone(), + acp::ContentBlock::Resource(resource) => match &resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(text) => text.text.clone(), + _ => String::new(), + }, + acp::ContentBlock::Image(_) => "[image]".to_string(), + acp::ContentBlock::Audio(_) => "[audio]".to_string(), + _ => String::new(), + } +} + +/// The approval an agent's `session/request_permission` becomes. +fn approval_request( + id: String, + turn_id: Option, + tool: &acp::ToolCallUpdate, + options: &[acp::PermissionOption], +) -> ApprovalRequest { + let fields = &tool.fields; + let title = fields.title.clone().unwrap_or_default(); + let kind = fields.kind.unwrap_or_default(); + let raw_input = fields.raw_input.clone().unwrap_or(Value::Null); + let approval_kind = match kind { + acp::ToolKind::Execute => ApprovalKind::ExecCommand { + command: command_of(&ToolState { + title: title.clone(), + raw_input: fields.raw_input.clone(), + ..Default::default() + }), + cwd: None, + reason: None, + }, + acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move => { + let changes = file_changes(&ToolState { + title: title.clone(), + kind, + content: fields.content.clone().unwrap_or_default(), + locations: fields.locations.clone().unwrap_or_default(), + raw_input: fields.raw_input.clone(), + ..Default::default() + }); + if changes.is_empty() { + ApprovalKind::ToolUse { + name: title.clone(), + input: raw_input, + detail: title, + } + } else { + ApprovalKind::FileChange { + changes, + reason: None, + } + } + } + acp::ToolKind::Read | acp::ToolKind::Search | acp::ToolKind::Fetch => { + ApprovalKind::FileRead { detail: title } + } + _ => ApprovalKind::ToolUse { + name: title.clone(), + input: raw_input, + detail: title, + }, + }; + ApprovalRequest { + id, + turn_id, + kind: approval_kind, + options: options + .iter() + .map(|option| ApprovalOption { + id: option.option_id.0.to_string(), + label: option.name.clone(), + kind: match option.kind { + acp::PermissionOptionKind::AllowOnce => ApprovalOptionKind::AllowOnce, + acp::PermissionOptionKind::AllowAlways => ApprovalOptionKind::AllowAlways, + acp::PermissionOptionKind::RejectAlways => ApprovalOptionKind::RejectAlways, + _ => ApprovalOptionKind::RejectOnce, + }, + }) + .collect(), + } +} + +/// The standard client services the agent calls into. +#[derive(Clone)] +struct Client { + events: Sender, + state: Arc>, +} + +impl Client { + fn session(&self, connection: Connection) -> Session { + Session { + connection, + events: self.events.clone(), + state: self.state.clone(), + } + } + + async fn emit_all(&self, events: Vec) { + for event in events { + let _ = self.events.send(event).await; + } + } + + async fn request_permission( + &self, + args: acp::RequestPermissionRequest, + ) -> Result { + let (request_id, turn) = { + let mut state = self.state.lock_recover(); + state.approval_seq += 1; + ( + format!("acp-approval-{}", state.approval_seq), + state.turn().map(str::to_owned), + ) + }; + let request = approval_request(request_id.clone(), turn, &args.tool_call, &args.options); + let (responder, decided) = smol::channel::bounded(1); + { + let mut state = self.state.lock_recover(); + state + .approvals + .insert(request_id.clone(), (responder, request.options.clone())); + // Keep the tool card in step with what we are asking about. + let id = args.tool_call.tool_call_id.0.to_string(); + let entry = state.tools.entry(id).or_default(); + if let Some(title) = args.tool_call.fields.title.clone() { + entry.title = title; + } + if let Some(kind) = args.tool_call.fields.kind { + entry.kind = kind; + } + if let Some(raw_input) = args.tool_call.fields.raw_input.clone() { + entry.raw_input = Some(raw_input); + } + } + let _ = self + .events + .send(AgentEvent::ApprovalRequested(request)) + .await; + + let outcome = decided + .recv() + .await + .unwrap_or(acp::RequestPermissionOutcome::Cancelled); + self.state.lock_recover().approvals.remove(&request_id); + Ok(acp::RequestPermissionResponse::new(outcome)) + } + + async fn read_text_file( + &self, + args: acp::ReadTextFileRequest, + ) -> Result { + let path = self.state.lock_recover().resolve_path(&args.path)?; + let content = smol::fs::read_to_string(&path).await.map_err(|err| { + acp::Error::new(-32603, format!("could not read {}: {err}", path.display())) + })?; + // `line` is 1-based; `limit` counts lines from there. + let content = match (args.line, args.limit) { + (None, None) => content, + (line, limit) => { + let start = line.unwrap_or(1).saturating_sub(1) as usize; + let lines: Vec<&str> = content.lines().skip(start).collect(); + let end = limit.map_or(lines.len(), |limit| lines.len().min(limit as usize)); + lines[..end].join("\n") + } + }; + Ok(acp::ReadTextFileResponse::new(content)) + } + + async fn write_text_file( + &self, + args: acp::WriteTextFileRequest, + ) -> Result { + let path = self.state.lock_recover().resolve_path(&args.path)?; + if let Some(parent) = path.parent() { + smol::fs::create_dir_all(parent).await.map_err(|err| { + acp::Error::new( + -32603, + format!("could not create {}: {err}", parent.display()), + ) + })?; + } + smol::fs::write(&path, args.content).await.map_err(|err| { + acp::Error::new(-32603, format!("could not write {}: {err}", path.display())) + })?; + Ok(acp::WriteTextFileResponse::new()) + } + + async fn create_terminal( + &self, + args: acp::CreateTerminalRequest, + connection: &Connection, + ) -> Result { + let cwd = match &args.cwd { + Some(cwd) => self.state.lock_recover().resolve_path(cwd)?, + None => self.state.lock_recover().cwd.clone(), + }; + let terminal = Terminal::spawn( + connection, + &args.command, + &args.args, + &args.env, + &cwd, + args.output_byte_limit + .unwrap_or(DEFAULT_TERMINAL_OUTPUT_LIMIT), + )?; + let id = { + let mut state = self.state.lock_recover(); + state.terminal_seq += 1; + let id = format!("term-{}", state.terminal_seq); + state.terminals.insert(id.clone(), terminal); + id + }; + Ok(acp::CreateTerminalResponse::new(acp::TerminalId::new(id))) + } + + async fn terminal_output( + &self, + args: acp::TerminalOutputRequest, + ) -> Result { + let terminal = self.terminal(&args.terminal_id)?; + let output = terminal.output.lock_recover().clone(); + let truncated = *terminal.truncated.lock_recover(); + let exit_status = terminal.exit.lock_recover().clone(); + Ok(acp::TerminalOutputResponse::new(output, truncated).exit_status(exit_status)) + } + + async fn wait_for_terminal_exit( + &self, + args: acp::WaitForTerminalExitRequest, + ) -> Result { + let terminal = self.terminal(&args.terminal_id)?; + // The sender is dropped once the process exits, closing the channel. + let _ = terminal.done.recv().await; + let exit_status = terminal + .exit + .lock_recover() + .clone() + .unwrap_or_else(acp::TerminalExitStatus::new); + Ok(acp::WaitForTerminalExitResponse::new(exit_status)) + } + + async fn kill_terminal( + &self, + args: acp::KillTerminalRequest, + ) -> Result { + self.terminal(&args.terminal_id)?.kill(); + Ok(acp::KillTerminalResponse::new()) + } + + async fn release_terminal( + &self, + args: acp::ReleaseTerminalRequest, + ) -> Result { + let terminal = self + .state + .lock_recover() + .terminals + .remove(args.terminal_id.0.as_ref()); + if let Some(terminal) = terminal { + terminal.kill(); + } + Ok(acp::ReleaseTerminalResponse::new()) + } + fn terminal(&self, id: &acp::TerminalId) -> Result, acp::Error> { + self.state + .lock_recover() + .terminals + .get(id.0.as_ref()) + .cloned() + .ok_or_else(|| acp::Error::new(-32002, format!("unknown terminal `{}`", id.0))) + } +} + +/// A command the agent asked us to run. Headless: we capture the output and +/// serve `terminal/output` / `terminal/wait_for_exit` from it, and the text is +/// folded into the owning tool card (`ToolCallContent::Terminal`). Wiring these +/// into the terminal drawer needs a canonical event the contract does not have. +struct Terminal { + child: Mutex, + output: Mutex, + truncated: Mutex, + exit: Mutex>, + /// Closed (never sent on) once the process has exited. + done: Receiver<()>, +} + +impl Terminal { + fn spawn( + connection: &Connection, + command: &str, + args: &[String], + env: &[acp::EnvVariable], + cwd: &Path, + limit: u64, + ) -> Result, acp::Error> { + let program = crate::resolve_binary(None, command) + .map_err(|err| acp::Error::new(-32603, err.to_string()))?; + let mut cmd = crate::process::async_command(&program); + cmd.args(args) + .current_dir(cwd) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + for var in env { + cmd.env(&var.name, &var.value); + } + let mut child = cmd + .spawn() + .map_err(|err| acp::Error::new(-32603, format!("could not run `{command}`: {err}")))?; + let stdout = child.stdout.take().ok_or_else(|| { + acp::Error::new(-32603, format!("`{command}` started without piped stdout")) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + acp::Error::new(-32603, format!("`{command}` started without piped stderr")) + })?; + let (done_tx, done) = smol::channel::bounded::<()>(1); + + let terminal = Arc::new(Terminal { + child: Mutex::new(child), + output: Mutex::new(String::new()), + truncated: Mutex::new(false), + exit: Mutex::new(None), + done, + }); + + // stdout and stderr interleave into one buffer, as they do in a terminal. + let streams: [Box; 2] = + [Box::new(stdout), Box::new(stderr)]; + for stream in streams { + connection.spawn({ + let terminal = terminal.clone(); + async move { + let mut stream = stream; + let mut buf = [0u8; 4096]; + loop { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(read) => terminal.append(&buf[..read], limit), + } + } + Ok(()) + } + })?; + } + + connection.spawn({ + let terminal = terminal.clone(); + async move { + loop { + let status = terminal.child.lock_recover().try_status(); + match status { + Ok(Some(status)) => { + *terminal.exit.lock_recover() = Some(exit_status(&status)); + break; + } + Err(err) => { + log::warn!("acp terminal: {err}"); + break; + } + Ok(None) => smol::Timer::after(Duration::from_millis(25)).await, + }; + } + // Closing the channel wakes every `wait_for_exit`. + drop(done_tx); + Ok(()) + } + })?; + + Ok(terminal) + } + + fn append(&self, bytes: &[u8], limit: u64) { + let mut output = self.output.lock_recover(); + output.push_str(&String::from_utf8_lossy(bytes)); + let limit = limit as usize; + if output.len() > limit { + // Keep the tail (what ACP asks for), cutting on a char boundary. + let mut cut = output.len() - limit; + while !output.is_char_boundary(cut) { + cut += 1; + } + output.drain(..cut); + *self.truncated.lock_recover() = true; + } + } + + fn kill(&self) { + let _ = self.child.lock_recover().kill(); + } +} + +fn exit_status(status: &std::process::ExitStatus) -> acp::TerminalExitStatus { + #[cfg(unix)] + let signal = { + use std::os::unix::process::ExitStatusExt as _; + status.signal().map(|signal| signal.to_string()) + }; + #[cfg(not(unix))] + let signal: Option = None; + acp::TerminalExitStatus::new() + .exit_code(status.code().map(|code| code as u32)) + .signal(signal) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn state() -> State { + State::new(PathBuf::from("/tmp/tcode-acp-test")) + } + + fn update(json: Value) -> acp::SessionUpdate { + serde_json::from_value(json).expect("valid session/update payload") + } + + fn modes(current: &str, available: &[&str]) -> acp::SessionModeState { + serde_json::from_value(json!({ + "currentModeId": current, + "availableModes": available + .iter() + .map(|id| json!({ "id": id, "name": id })) + .collect::>() + })) + .unwrap() + } + + #[test] + fn plan_mode_requires_advertisement_and_restores_the_previous_mode() { + for previous in ["build", "review"] { + let mut state = state(); + state.ingest_modes(Some(&modes(previous, &["build", "review", "plan"]))); + let plan = interaction_mode_target(InteractionMode::Plan, &state).unwrap(); + assert_eq!(plan, acp::SessionModeId::new("plan")); + state.select_mode(plan); + assert_eq!( + interaction_mode_target(InteractionMode::Build, &state), + Some(acp::SessionModeId::new(previous)) + ); + } + let mut state = state(); + state.ingest_modes(Some(&modes("build", &["build", "review"]))); + assert_eq!(interaction_mode_target(InteractionMode::Plan, &state), None); + assert!(matches!(missing_mode_warning(InteractionMode::Plan), + AgentEvent::Warning { message } if message.contains("does not advertise a Plan mode"))); + } + + #[test] + fn prompt_acceptance_follows_the_complete_stdio_write() { + smol::block_on(async { + use smol::prelude::*; + + let pending = Arc::new(Mutex::new(VecDeque::from([41]))); + let (events, received) = smol::channel::unbounded(); + let inner = smol::io::Cursor::new(Vec::new()); + let mut writer = ObservedWriter::new(inner, pending.clone(), events); + + writer + .write_all( + br#"{"jsonrpc":"2.0","method":"initialize"} +"#, + ) + .await + .unwrap(); + writer + .write_all(br#"{"jsonrpc":"2.0","method":"session/prompt"}"#) + .await + .unwrap(); + assert!( + received.try_recv().is_err(), + "an incomplete line must not ack" + ); + + writer.write_all(b"\n").await.unwrap(); + assert!(matches!( + received.recv().await.unwrap(), + AgentEvent::TurnAccepted { delivery_id: 41 } + )); + assert!(pending.lock_recover().is_empty()); + }); + } + + /// The preview MCP server is a loopback HTTP endpoint: it may only be handed + /// to agents that advertise `mcpCapabilities.http`. + #[test] + fn mcp_server_is_gated_on_the_http_capability() { + let registration = McpRegistration { + name: McpRegistration::SERVER_NAME_PREVIEW.into(), + url: "http://127.0.0.1:5321/mcp".into(), + bearer_token: "tok".into(), + }; + let mut caps = acp::AgentCapabilities::default(); + assert!( + mcp_servers(&[®istration], &caps).is_empty(), + "an agent without mcpCapabilities.http must not be sent the HTTP server" + ); + + caps.mcp_capabilities.http = true; + let servers = mcp_servers(&[®istration], &caps); + let value = serde_json::to_value(&servers[0]).unwrap(); + assert_eq!(value["type"], "http"); + assert_eq!(value["name"], "tcode_preview"); + assert_eq!(value["url"], "http://127.0.0.1:5321/mcp"); + assert_eq!(value["headers"][0]["name"], "Authorization"); + assert_eq!(value["headers"][0]["value"], "Bearer tok"); + + // No preview server running → nothing to send, capability or not. + assert!(mcp_servers(&[], &caps).is_empty()); + + let orchestrate = McpRegistration { + name: McpRegistration::SERVER_NAME_ORCHESTRATE.into(), + url: "http://127.0.0.1:5321/mcp".into(), + bearer_token: "other".into(), + }; + assert_eq!(mcp_servers(&[®istration, &orchestrate], &caps).len(), 2); + assert_eq!(mcp_servers(&[&orchestrate], &caps).len(), 1); + + let computer_use = McpRegistration { + name: McpRegistration::SERVER_NAME_COMPUTER_USE.into(), + url: "http://127.0.0.1:5322/mcp".into(), + bearer_token: "computer-token".into(), + }; + let servers = mcp_servers(&[®istration, &orchestrate, &computer_use], &caps); + assert_eq!(servers.len(), 3); + let value = serde_json::to_value(&servers[2]).unwrap(); + assert_eq!(value["name"], "tcode_computer_use"); + assert_eq!(value["headers"][0]["value"], "Bearer computer-token"); + } + + #[test] + fn streamed_text_keeps_block_identity_across_reasoning_and_ignores_user_echoes() { + let mut state = state(); + let mut previous = None; + for (kind, wire_kind, chunks, expected) in [ + ( + DeltaKind::AssistantText, + "agent_message_chunk", + ["Hel", "lo"], + "Hello", + ), + ( + DeltaKind::ReasoningText, + "agent_thought_chunk", + ["Think", "ing"], + "Thinking", + ), + ( + DeltaKind::AssistantText, + "agent_message_chunk", + ["An", "swer"], + "Answer", + ), + ] { + let mut id = None; + for chunk in chunks { + let mut events = state + .apply_update(update(json!({ + "sessionUpdate": wire_kind, "content": {"type":"text", "text":chunk} + }))) + .into_iter(); + if let Some((previous_id, previous_kind, previous_text)) = previous.take() { + let Some(AgentEvent::ItemCompleted(item)) = events.next() else { + panic!("changing text kind must complete the previous block"); + }; + assert_eq!(item.id, previous_id); + match (previous_kind, item.content) { + (DeltaKind::AssistantText, ItemContent::AssistantMessage { text }) + | (DeltaKind::ReasoningText, ItemContent::Reasoning { text }) => { + assert_eq!(text, previous_text) + } + other => panic!("wrong completed block: {other:?}"), + } + } + let Some(AgentEvent::Delta { + item_id, + kind: actual_kind, + text, + }) = events.next() + else { + panic!("text chunk must stream a delta"); + }; + assert_eq!(actual_kind, kind); + assert_eq!(text, chunk); + if let Some(id) = &id { + assert_eq!(&item_id, id); + } + id = Some(item_id); + assert!(events.next().is_none()); + assert!(state.apply_update(update(json!({ + "sessionUpdate":"user_message_chunk", "content":{"type":"text", "text":"prompt echo"} + }))).is_empty()); + } + previous = Some((id.unwrap(), kind, expected)); + } + let (id, _, expected) = previous.unwrap(); + assert!(matches!(state.flush_text().as_slice(), + [AgentEvent::ItemCompleted(ThreadItem { id: actual_id, content: ItemContent::AssistantMessage { text }, .. })] + if actual_id == &id && text == expected)); + assert!(state.flush_text().is_empty()); + } + + #[test] + fn execute_tool_call_maps_to_a_command_execution() { + let mut state = state(); + let events = state.apply_update(update(json!({ + "sessionUpdate": "tool_call", + "toolCallId": "t1", + "title": "Run tests", + "kind": "execute", + "status": "in_progress", + "rawInput": { "command": "cargo test" } + }))); + match &events[0] { + AgentEvent::ItemStarted(item) => { + assert_eq!(item.id, "t1"); + match &item.content { + ItemContent::CommandExecution { + command, status, .. + } => { + assert_eq!(command, "cargo test"); + assert_eq!(*status, ItemStatus::InProgress); + } + other => panic!("expected CommandExecution, got {other:?}"), + } + } + other => panic!("expected ItemStarted, got {other:?}"), + } + + // A partial patch merges into the merged state and completes the item. + let events = state.apply_update(update(json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "t1", + "status": "completed", + "content": [{ "type": "content", "content": { "type": "text", "text": "ok" } }], + "rawOutput": { "exitCode": 0 } + }))); + match &events[0] { + AgentEvent::ItemCompleted(item) => match &item.content { + ItemContent::CommandExecution { + command, + output, + exit_code, + status, + } => { + assert_eq!(command, "cargo test", "rawInput must survive the patch"); + assert_eq!(output, "ok"); + assert_eq!(*exit_code, Some(0)); + assert_eq!(*status, ItemStatus::Completed); + } + other => panic!("expected CommandExecution, got {other:?}"), + }, + other => panic!("expected ItemCompleted, got {other:?}"), + } + } + + #[test] + fn file_tools_preserve_operation_kind_and_render_available_change_evidence() { + for (kind, old, new, operation, diff) in [ + ( + "edit", + Some("same\nbefore\nend\n"), + "same\nafter\nend\n", + FileChangeKind::Modify, + "--- a/file.rs\n+++ b/file.rs\n@@ -1,3 +1,3 @@\n same\n-before\n+after\n end\n", + ), + ( + "edit", + None, + "new\n", + FileChangeKind::Create, + "--- /dev/null\n+++ b/file.rs\n@@ -0,0 +1,1 @@\n+new\n", + ), + ( + "edit", + Some(""), + "new\n", + FileChangeKind::Create, + "--- /dev/null\n+++ b/file.rs\n@@ -0,0 +1,1 @@\n+new\n", + ), + ( + "delete", + Some("old\n"), + "", + FileChangeKind::Delete, + "--- a/file.rs\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-old\n", + ), + ( + "move", + Some("old\n"), + "new\n", + FileChangeKind::Rename, + "--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,1 @@\n-old\n+new\n", + ), + ] { + let events = state().apply_update(update(json!({ + "sessionUpdate":"tool_call", "toolCallId":"file", "title":"Change file", + "kind":kind, "status":"completed", + "content":[{"type":"diff","path":"file.rs","oldText":old,"newText":new}] + }))); + let [ + AgentEvent::ItemStarted(ThreadItem { + content: ItemContent::FileChange { changes, status }, + .. + }), + ] = events.as_slice() + else { + panic!("{kind}: expected file item, got {events:?}"); + }; + assert_eq!(*status, ItemStatus::Completed); + assert_eq!( + changes, + &[FileChange { + path: "file.rs".into(), + kind: operation, + diff: Some(diff.into()) + }] + ); + } + for (kind, operation) in [ + ("delete", FileChangeKind::Delete), + ("move", FileChangeKind::Rename), + ] { + let events = state().apply_update(update(json!({ + "sessionUpdate":"tool_call", "toolCallId":"file", "title":"Change file", + "kind":kind, "status":"completed", "locations":[{"path":"one.rs"},{"path":"two.rs"}] + }))); + let [ + AgentEvent::ItemStarted(ThreadItem { + content: ItemContent::FileChange { changes, .. }, + .. + }), + ] = events.as_slice() + else { + panic!("{kind}: expected file locations, got {events:?}"); + }; + assert_eq!( + changes, + &[ + FileChange { + path: "one.rs".into(), + kind: operation, + diff: None + }, + FileChange { + path: "two.rs".into(), + kind: operation, + diff: None + }, + ] + ); + } + let events = state().apply_update(update(json!({ + "sessionUpdate":"tool_call", "toolCallId":"file", "title":"Opaque edit", + "kind":"edit", "status":"completed", "rawInput":{"path":"file.rs"}, "rawOutput":"done" + }))); + assert!( + matches!(events.as_slice(), [AgentEvent::ItemStarted(ThreadItem { + content:ItemContent::ToolCall { name, input, output:Some(output), status:ItemStatus::Completed }, .. + })] if name == "Opaque edit" && input == &json!({"path":"file.rs"}) && output == "done") + ); + } + + #[test] + fn read_search_fetch_and_other_map_to_tool_cards_with_raw_payloads() { + for (kind, id) in [ + ("read", "r1"), + ("search", "r2"), + ("fetch", "r3"), + ("other", "r4"), + ("switch_mode", "r5"), + ] { + let mut state = state(); + let events = state.apply_update(update(json!({ + "sessionUpdate": "tool_call", + "toolCallId": id, + "title": "Read file", + "kind": kind, + "status": "completed", + "rawInput": { "path": "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/repo/x.rs" }, + "content": [{ "type": "content", "content": { "type": "text", "text": "body" } }] + }))); + match &events[0] { + AgentEvent::ItemStarted(item) => match &item.content { + ItemContent::ToolCall { + name, + input, + output, + status, + } => { + assert_eq!(name, "Read file"); + assert_eq!(input["path"], "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/repo/x.rs", "rawInput must ride along"); + assert_eq!(output.as_deref(), Some("body")); + assert_eq!(*status, ItemStatus::Completed); + } + other => panic!("{kind}: expected ToolCall, got {other:?}"), + }, + other => panic!("{kind}: expected ItemStarted, got {other:?}"), + } + } + } + + #[test] + fn think_tool_calls_become_reasoning() { + let mut state = state(); + let events = state.apply_update(update(json!({ + "sessionUpdate": "tool_call", + "toolCallId": "t4", + "title": "Thinking", + "kind": "think", + "status": "completed", + "content": [{ "type": "content", "content": { "type": "text", "text": "step 1" } }] + }))); + match &events[0] { + AgentEvent::ItemStarted(item) => match &item.content { + ItemContent::Reasoning { text } => assert_eq!(text, "step 1"), + other => panic!("expected Reasoning, got {other:?}"), + }, + other => panic!("expected ItemStarted, got {other:?}"), + } + } + + #[test] + fn tool_updates_recover_missing_starts_and_preserve_status_and_input() { + for (wire, expected) in [ + ("pending", ItemStatus::InProgress), + ("in_progress", ItemStatus::InProgress), + ("completed", ItemStatus::Completed), + ("failed", ItemStatus::Failed), + ] { + let mut state = state(); + let events = state.apply_update(update(json!({ + "sessionUpdate":"tool_call_update", "toolCallId":"late", "title":"Late tool", + "kind":"other", "status":"pending", "rawInput":{"path":"a.rs"} + }))); + assert!(matches!(events.as_slice(), [AgentEvent::ItemStarted(_)])); + let events = state.apply_update(update(json!({ + "sessionUpdate":"tool_call_update", "toolCallId":"late", "status":wire + }))); + let item = match events.as_slice() { + [AgentEvent::ItemUpdated(item)] if expected == ItemStatus::InProgress => item, + [AgentEvent::ItemCompleted(item)] if expected != ItemStatus::InProgress => item, + other => panic!("{wire}: incorrect lifecycle event {other:?}"), + }; + assert!( + matches!(&item.content, ItemContent::ToolCall { name, input, status, .. } + if name == "Late tool" && input == &json!({"path":"a.rs"}) && *status == expected) + ); + } + } + + #[test] + fn plan_update_preserves_turn_and_steps() { + let mut state = state(); + state.turn = Some("turn-1".into()); + let events = state.apply_update(update(json!({ + "sessionUpdate": "plan", + "entries": [ + { "content": "Read the code", "priority": "high", "status": "completed" }, + { "content": "Write the fix", "priority": "medium", "status": "in_progress" } + ] + }))); + match &events[0] { + AgentEvent::PlanUpdated { + turn_id, + steps, + explanation, + } => { + assert_eq!(turn_id.as_deref(), Some("turn-1")); + assert!(explanation.is_none()); + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].step, "Read the code"); + assert_eq!(steps[0].status, PlanStepStatus::Completed); + assert_eq!(steps[1].status, PlanStepStatus::InProgress); + } + other => panic!("expected PlanUpdated, got {other:?}"), + } + } + + #[test] + fn available_commands_become_provider_commands() { + let mut state = state(); + let events = state.apply_update(update(json!({ + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { "name": "review", "description": "Review the diff", "input": null } + ] + }))); + match &events[0] { + AgentEvent::ProviderCommands { commands } => { + assert_eq!(commands[0].name, "review"); + assert_eq!(commands[0].kind, ProviderCommandKind::Command); + } + other => panic!("expected ProviderCommands, got {other:?}"), + } + } + + #[test] + fn usage_update_is_the_context_window() { + let mut state = state(); + let events = state.apply_update(update(json!({ + "sessionUpdate": "usage_update", + "used": 1200, + "size": 200000 + }))); + match &events[0] { + AgentEvent::TokenUsage(usage) => { + assert_eq!(usage.used_tokens, Some(1200)); + assert_eq!(usage.context_window, Some(200_000)); + } + other => panic!("expected TokenUsage, got {other:?}"), + } + } + + #[test] + fn modes_and_categorized_config_become_provider_options() { + let mut state = state(); + let modes: acp::SessionModeState = serde_json::from_value(json!({ + "currentModeId": "build", + "availableModes": [ + { "id": "build", "name": "Build" }, + { "id": "plan", "name": "Plan", "description": "Read-only" } + ] + })) + .unwrap(); + let config: Vec = serde_json::from_value(json!([ + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "sonnet", + "options": [{ "value": "sonnet", "name": "Sonnet" }] + }, + { + "id": "thought_level", + "name": "Thinking", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { "value": "low", "name": "Low" }, + { "value": "medium", "name": "Medium" } + ] + }, + { "id": "web", "name": "Web search", "type": "boolean", "currentValue": true } + ])) + .unwrap(); + state.options.ingest(Some(&modes), Some(&config)); + assert_eq!(state.options.current_model(), Some("sonnet".to_string())); + + let descriptors: Vec<_> = state + .options + .records + .iter() + .map(|record| record.descriptor.clone()) + .collect(); + let ids: Vec<&str> = descriptors.iter().map(descriptor_id).collect(); + assert_eq!( + ids, + vec![ + "acp:mode", + "acp:model", + "acp:cfg:thought_level", + "acp:cfg:web" + ] + ); + assert_eq!(state.options.origin("acp:mode"), Some(OptionOrigin::Mode)); + assert_eq!( + state.options.origin("acp:model"), + Some(OptionOrigin::Config(acp::SessionConfigId::new("model"))) + ); + assert_eq!( + state.options.origin("acp:cfg:web"), + Some(OptionOrigin::Config(acp::SessionConfigId::new("web"))) + ); + assert!(matches!( + &descriptors[3], + OptionDescriptor::Boolean { + default_value: true, + .. + } + )); + + let selections: Vec<_> = state + .options + .records + .iter() + .map(|record| record.selection.clone()) + .collect(); + assert_eq!(selections[0].value, json!("build")); + assert_eq!(selections[1].value, json!("sonnet")); + assert_eq!(selections[2].value, json!("medium")); + assert_eq!(selections[3].value, json!(true)); + + // An agent-initiated mode switch re-publishes the options… + let events = state.apply_update(update(json!({ + "sessionUpdate": "current_mode_update", + "currentModeId": "plan" + }))); + match &events[0] { + AgentEvent::ProviderOptions { selections, .. } => { + assert_eq!(selections[0].value, json!("plan")); + } + other => panic!("expected ProviderOptions, got {other:?}"), + } + + // …and so does a config-option push. + let events = state.apply_update(update(json!({ + "sessionUpdate": "config_option_update", + "configOptions": [ + { "id": "web", "name": "Web search", "type": "boolean", "currentValue": false } + ] + }))); + match &events[0] { + AgentEvent::ProviderOptions { selections, .. } => { + let web = selections.iter().find(|s| s.id == "acp:cfg:web").unwrap(); + assert_eq!(web.value, json!(false)); + } + other => panic!("expected ProviderOptions, got {other:?}"), + } + } + + #[test] + fn permission_request_carries_the_agents_own_options() { + let request: acp::RequestPermissionRequest = serde_json::from_value(json!({ + "sessionId": "s1", + "toolCall": { + "toolCallId": "t9", + "title": "rm -rf build", + "kind": "execute", + "rawInput": { "command": "rm -rf build" } + }, + "options": [ + { "optionId": "yes", "name": "Allow", "kind": "allow_once" }, + { "optionId": "always", "name": "Always allow", "kind": "allow_always" }, + { "optionId": "no", "name": "Reject", "kind": "reject_once" } + ] + })) + .unwrap(); + let approval = approval_request( + "acp-approval-1".into(), + Some("turn-1".into()), + &request.tool_call, + &request.options, + ); + match &approval.kind { + ApprovalKind::ExecCommand { command, .. } => assert_eq!(command, "rm -rf build"), + other => panic!("expected ExecCommand, got {other:?}"), + } + assert_eq!(approval.options.len(), 3); + assert_eq!(approval.options[0].label, "Allow"); + assert_eq!(approval.options[1].kind, ApprovalOptionKind::AllowAlways); + + // Our fixed four map onto the agent's own options… + let selected = + |decision: ApprovalDecision| match approval_outcome(&decision, &approval.options) { + Some(acp::RequestPermissionOutcome::Selected(outcome)) => { + outcome.option_id.0.to_string() + } + other => panic!("expected a selection, got {other:?}"), + }; + assert_eq!(selected(ApprovalDecision::Approve), "yes"); + assert_eq!(selected(ApprovalDecision::Deny), "no"); + // A persistent native option is chosen only explicitly, and only one + // the agent offered. + assert_eq!(selected(ApprovalDecision::ApproveForSession), "yes"); + assert_eq!( + selected(ApprovalDecision::Option("always".into())), + "always" + ); + assert!( + approval_outcome(&ApprovalDecision::Option("other".into()), &approval.options) + .is_none() + ); + // …and Cancel is the protocol's own `cancelled` outcome. + assert!(matches!( + approval_outcome(&ApprovalDecision::Cancel, &approval.options), + Some(acp::RequestPermissionOutcome::Cancelled) + )); + + // The exact wire shape the agent expects back. + let response = acp::RequestPermissionResponse::new( + approval_outcome(&ApprovalDecision::Approve, &approval.options).unwrap(), + ); + let value = serde_json::to_value(&response).unwrap(); + assert_eq!(value["outcome"]["outcome"], "selected"); + assert_eq!(value["outcome"]["optionId"], "yes"); + // No fixed decision falls back to a persistent option. + let persistent_only: Vec<_> = [ + ApprovalOptionKind::AllowAlways, + ApprovalOptionKind::RejectAlways, + ] + .into_iter() + .map(|kind| ApprovalOption { + id: format!("{kind:?}"), + label: format!("{kind:?}"), + kind, + }) + .collect(); + for decision in [ + ApprovalDecision::Approve, + ApprovalDecision::ApproveForSession, + ApprovalDecision::Deny, + ] { + assert!(approval_outcome(&decision, &persistent_only).is_none()); + } + } + + #[test] + fn approvals_classify_by_tool_kind() { + let build = |kind: &str| { + let tool: acp::ToolCallUpdate = serde_json::from_value(json!({ + "toolCallId": "t", + "title": "Read config", + "kind": kind, + "rawInput": { "path": "a.rs" } + })) + .unwrap(); + approval_request("a1".into(), None, &tool, &[]).kind + }; + assert!(matches!(build("read"), ApprovalKind::FileRead { .. })); + assert!(matches!(build("search"), ApprovalKind::FileRead { .. })); + assert!(matches!(build("fetch"), ApprovalKind::FileRead { .. })); + assert!(matches!(build("other"), ApprovalKind::ToolUse { .. })); + + let edit: acp::ToolCallUpdate = serde_json::from_value(json!({ + "toolCallId": "t", + "title": "Edit", + "kind": "edit", + "content": [{ "type": "diff", "path": "a.rs", "oldText": "a\n", "newText": "b\n" }] + })) + .unwrap(); + match approval_request("a2".into(), None, &edit, &[]).kind { + ApprovalKind::FileChange { changes, .. } => assert_eq!(changes[0].path, "a.rs"), + other => panic!("expected FileChange, got {other:?}"), + } + } + + #[test] + fn stop_reasons_map_to_turn_status() { + assert_eq!( + stop_reason_status(acp::StopReason::EndTurn).0, + TurnStatus::Completed + ); + assert_eq!( + stop_reason_status(acp::StopReason::Cancelled).0, + TurnStatus::Interrupted + ); + let (status, message) = stop_reason_status(acp::StopReason::Refusal); + assert_eq!(status, TurnStatus::Failed); + assert!(message.unwrap().contains("refused")); + let (status, message) = stop_reason_status(acp::StopReason::MaxTokens); + assert_eq!(status, TurnStatus::Failed); + assert!(message.unwrap().contains("token limit")); + } + + #[test] + fn paths_outside_the_session_cwd_are_rejected() { + let state = state(); + assert!(state.resolve_path(Path::new("src/main.rs")).is_ok()); + assert!( + state + .resolve_path(Path::new("/tmp/tcode-acp-test/../etc/passwd")) + .is_err() + ); + assert!(state.resolve_path(Path::new("/etc/passwd")).is_err()); + } + + #[test] + fn prompt_carries_text_and_image_blocks() { + let blocks = prompt_blocks( + "hello", + &[Attachment { + media_type: "image/png".into(), + data_base64: "AAAA".into(), + source_path: None, + }], + ); + let value = serde_json::to_value(&blocks).unwrap(); + assert_eq!(value[0]["type"], "text"); + assert_eq!(value[0]["text"], "hello"); + assert_eq!(value[1]["type"], "image"); + assert_eq!(value[1]["mimeType"], "image/png"); + assert_eq!(value[1]["data"], "AAAA"); + } +} diff --git a/crates/agent/src/cursor.rs b/crates/agent/src/cursor.rs new file mode 100644 index 000000000..fc5db88f4 --- /dev/null +++ b/crates/agent/src/cursor.rs @@ -0,0 +1,718 @@ +//! Cursor: `cursor-agent [--force] [flags] acp`, an ACP agent with Cursor's +//! `cursor/*` dialect. +//! +//! The protocol machinery is [`crate::acp_session`]; this module is Cursor's +//! dialect. Its `session/update` mapping beyond the standard one lives in +//! [`updates`]. + +mod updates; + +#[cfg(all(test, unix))] +mod tests; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, MutexGuard}; + +use agent_client_protocol::{UntypedMessage, schema::v1 as acp}; +use serde_json::{Map, Value, json}; + +use crate::acp_session::{ + self, Dialect, Established, Launch, Session, Setup, State, TurnEnd, describe, is_auth_required, + prompt_status, +}; +use crate::{ + AgentError, AgentEvent, ApprovalMode, Attachment, InteractionMode, LaunchEnv, ModelSpec, + PlanStep, PlanStepStatus, ProviderKind, ResumeCursor, SessionHandle, SessionOptions, + TurnStatus, UserInputOption, UserInputQuestion, +}; + +use updates::Updates; + +const ASK_QUESTION: &str = "cursor/ask_question"; +const CREATE_PLAN: &str = "cursor/create_plan"; +const UPDATE_TODOS: &str = "cursor/update_todos"; +const TASK: &str = "cursor/task"; +const GENERATE_IMAGE: &str = "cursor/generate_image"; +const LIST_AVAILABLE_MODELS: &str = "cursor/list_available_models"; + +/// The `configOptions` id of Cursor's model selector. The composer's model +/// picker owns the model ([`SessionOptions::model`]); this option is how the +/// session is put on it. +const MODEL_CONFIG_ID: &str = "model"; + +/// The assistant text Cursor's ACP server sends, as the turn's last output, +/// when its agent run throws; the turn then ends with `end_turn`. +const RUN_ERROR_PREFIX: &str = "\n\nError: "; +/// The same, for a run the backend refused until the account acts. +const RUN_ACTION_REQUIRED: [&str; 4] = [ + "\n\nPlease sign in to continue", + "\n\nUpgrade your plan to continue", + "\n\nAdd a payment method to continue", + "\n\nCheck your settings to continue", +]; + +/// Start (or resume) a Cursor session. +pub async fn start(opts: SessionOptions) -> Result { + let cursor = Cursor::new(&opts); + acp_session::start(ProviderKind::Cursor, cursor, opts).await +} + +/// The models `cursor/list_available_models` offers the signed-in account. +/// Each model's parameters arrive over the wire once a session runs it +/// ([`crate::OptionDescriptors::Wire`]); the catalog marks no default, so a +/// session without a model runs the one Cursor is set to. +pub async fn list_models( + binary_path: Option, + launch_env: LaunchEnv, +) -> Result, AgentError> { + let opts = SessionOptions { + cwd: std::env::temp_dir(), + model: None, + resume: None, + fork: false, + binary_path, + approval_mode: ApprovalMode::Supervised, + option_selections: Vec::new(), + interaction_mode: InteractionMode::default(), + mcp_servers: Vec::new(), + launch_env, + extra_args: Vec::new(), + acp: None, + }; + let cursor = Cursor::new(&opts); + acp_session::query( + ProviderKind::Cursor, + cursor, + opts, + async |connection, _init| { + let listed = connection + .send_request(UntypedMessage { + method: LIST_AVAILABLE_MODELS.into(), + params: json!({}), + }) + .block_task() + .await + .map_err(|err| { + if is_auth_required(&err) { + signed_out() + } else { + AgentError::Protocol(format!( + "Cursor could not list its models: {}", + describe(&err) + )) + } + })?; + Ok(catalog(&listed)) + }, + ) + .await +} + +/// `{models: [{value, name, configOptions}]}`. +fn catalog(listed: &Value) -> Vec { + listed + .get("models") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|model| { + let id = model.get("value")?.as_str()?; + Some(ModelSpec { + id: id.to_string(), + display_name: model + .get("name") + .and_then(Value::as_str) + .unwrap_or(id) + .to_string(), + is_default: false, + options: Vec::new(), + }) + }) + .collect() +} + +fn signed_out() -> AgentError { + AgentError::Provider( + "Cursor is not signed in. Run `cursor-agent login`, or set CURSOR_API_KEY in Settings → Providers → Cursor, then try again." + .into(), + ) +} + +struct Cursor { + approval_mode: ApprovalMode, + updates: Mutex, + /// Cursor's todo list as `cursor/update_todos` last left it. + todos: Mutex>, + /// Whether the running turn's latest output is Cursor's run-failure text. + run_failed: AtomicBool, +} + +impl Cursor { + fn new(opts: &SessionOptions) -> Self { + Self { + approval_mode: opts.approval_mode, + updates: Mutex::new(Updates::new(opts.cwd.clone())), + todos: Mutex::new(Vec::new()), + run_failed: AtomicBool::new(false), + } + } + + fn updates(&self) -> MutexGuard<'_, Updates> { + self.updates + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn track_run_failure(&self, update: &acp::SessionUpdate) { + let failed = match update { + acp::SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content { + acp::ContentBlock::Text(text) => { + text.text.starts_with(RUN_ERROR_PREFIX) + || RUN_ACTION_REQUIRED.contains(&text.text.as_str()) + } + _ => false, + }, + acp::SessionUpdate::AgentThoughtChunk(_) + | acp::SessionUpdate::ToolCall(_) + | acp::SessionUpdate::ToolCallUpdate(_) + | acp::SessionUpdate::Plan(_) => false, + _ => return, + }; + self.run_failed.store(failed, Ordering::Relaxed); + } + + /// `cursor/update_todos`: the list replaces Cursor's todos, or with + /// `merge` updates them by id. + fn update_todos(&self, params: &Value) -> Vec { + let incoming: Vec = params + .get("todos") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Todo::parse) + .collect(); + let merge = params + .get("merge") + .and_then(Value::as_bool) + .unwrap_or(false); + let mut todos = self + .todos + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if merge { + for todo in incoming { + match todos.iter_mut().find(|known| known.id == todo.id) { + Some(known) => *known = todo, + None => todos.push(todo), + } + } + } else { + *todos = incoming; + } + todos.iter().filter_map(Todo::step).collect() + } +} + +impl Dialect for Cursor { + fn name(&self) -> &str { + "Cursor" + } + + fn launch(&self, opts: &SessionOptions) -> Result { + // Not the generic `agent` name Cursor also installs: that name is too + // common to resolve from PATH with confidence. + let program = crate::resolve_binary(opts.binary_path.as_deref(), "cursor-agent")?; + let mut args = Vec::new(); + if opts.approval_mode == ApprovalMode::FullAccess { + args.push("--force".to_string()); + } + args.extend(opts.extra_args.iter().cloned()); + args.push("acp".to_string()); + let client_meta = json!({ "parameterizedModelPicker": true, "subagents": true }); + Ok(Launch { + program, + args, + env: Vec::new(), + client_meta: client_meta.as_object().cloned(), + }) + } + + async fn establish(&self, setup: &Setup<'_>) -> Result { + let (connection, opts) = (setup.connection, setup.opts); + if opts.fork { + return Err(AgentError::Protocol( + "session fork is not supported for this provider".into(), + )); + } + let mcp_servers = setup.mcp_servers().await; + let resumed = opts + .resume + .as_ref() + .and_then(|cursor| cursor.str_field(&["session_id"])) + .map(str::to_string); + // `authenticate` opens a browser when Cursor is signed out, so it is + // never driven from here: a missing login is reported instead. + let (session_id, modes, config_options) = match resumed { + Some(session_id) => { + let loaded = setup + .load( + acp::LoadSessionRequest::new(session_id.clone(), opts.cwd.clone()) + .mcp_servers(mcp_servers), + ) + .await + .map_err(|err| { + start_failure(&err, &format!("could not resume session {session_id}")) + })?; + ( + acp::SessionId::new(session_id), + loaded.modes, + loaded.config_options, + ) + } + None => { + let created = connection + .send_request( + acp::NewSessionRequest::new(opts.cwd.clone()).mcp_servers(mcp_servers), + ) + .block_task() + .await + .map_err(|err| start_failure(&err, "could not start a session"))?; + (created.session_id, created.modes, created.config_options) + } + }; + setup.adopt(modes.as_ref(), config_options.as_deref()); + let mut config_options = config_options.unwrap_or_default(); + if let Some(model) = &opts.model + && let Some(selected) = select_model(setup, &session_id, model, &config_options).await? + { + config_options = selected; + } + restore_parameters(setup, &session_id, &config_options).await; + if opts.interaction_mode == InteractionMode::Plan || setup.in_plan_mode() { + setup + .apply_interaction_mode(&session_id, opts.interaction_mode) + .await; + } + Ok(Established { + resume: ResumeCursor(json!({ "session_id": session_id.0.to_string() })), + session_id, + }) + } + + fn session_update( + &self, + state: &mut State, + notification: acp::SessionNotification, + ) -> Vec { + // `session/load` replays the whole conversation before it answers; + // tcode's own log is the conversation of record. + if state.loading() { + return Vec::new(); + } + let mut updates = self.updates(); + if !updates.is_subagent(¬ification.session_id) { + self.track_run_failure(¬ification.update); + } + updates.session_update(state, notification) + } + + fn turn_end( + &self, + _state: &mut State, + result: Result, + ) -> TurnEnd { + let (mut status, message) = prompt_status(&result); + // The failure text stays in the transcript as Cursor wrote it. + if self.run_failed.swap(false, Ordering::Relaxed) && status == TurnStatus::Completed { + status = TurnStatus::Failed; + } + // Cursor reports no usage over ACP. + TurnEnd { + status, + message, + usage: None, + } + } + + async fn steer( + &self, + _session: &Session, + _request_id: String, + _text: String, + _attachments: Vec, + ) { + } + + async fn set_approval_mode(&self, _session: &Session, _mode: ApprovalMode) { + // Applied at launch when the runtime restarts the session for the next + // turn. + } + + fn handles_request(&self, method: &str) -> bool { + matches!( + method, + ASK_QUESTION | CREATE_PLAN | UPDATE_TODOS | TASK | GENERATE_IMAGE + ) + } + + async fn request( + &self, + session: Session, + method: String, + params: Value, + ) -> Result { + // None of these is sent while `session/load` replays, but a replay + // must never put a question to the user. + let replaying = session.with_state(|state| state.loading()); + match method.as_str() { + ASK_QUESTION => { + let questions = Questions::parse(¶ms)?; + if replaying { + return Ok(json!({ "outcome": { "outcome": "cancelled" } })); + } + let answers = session.ask_user(questions.canonical()).await; + let (reply, unsent) = questions.reply(answers); + if !unsent.is_empty() { + session + .emit(AgentEvent::Warning { + message: format!( + "Cursor takes only the offered answers; the typed answer to {} was not sent.", + unsent.join(", ") + ), + }) + .await; + } + Ok(reply) + } + CREATE_PLAN => { + // Cursor's own CLI takes a created plan without asking: it + // saves the plan, and the user builds it in a later turn. An + // `accepted` outcome without a `planUri` has Cursor save it + // the same way; the user's decision is the proposed plan's. + let markdown = params.get("plan").and_then(Value::as_str).unwrap_or(""); + if !replaying && !markdown.trim().is_empty() { + let item_id = params + .get("toolCallId") + .and_then(Value::as_str) + .unwrap_or(CREATE_PLAN); + session + .emit(AgentEvent::ProposedPlan { + item_id: item_id.to_string(), + markdown: markdown.to_string(), + }) + .await; + } + Ok(json!({ "outcome": { "outcome": "accepted" } })) + } + UPDATE_TODOS => { + if !replaying { + let steps = self.update_todos(¶ms); + let turn_id = session.with_state(|state| state.turn().map(str::to_owned)); + session + .emit(AgentEvent::PlanUpdated { + turn_id, + explanation: None, + steps, + }) + .await; + } + Ok(json!({})) + } + // Sent once the task or image tool has finished; Cursor ignores + // the reply, and the tool's own updates carry its result. + _ => Ok(json!({})), + } + } + + fn notification(&self, state: &mut State, method: &str, params: Value) -> Vec { + if method != "session/update" { + log::trace!("cursor: {method} {params}"); + return Vec::new(); + } + if state.loading() { + return Vec::new(); + } + self.updates().subagent_update(¶ms) + } + + /// Cursor's file and shell tools are its own: it calls none of these + /// services, and not offering them keeps it that way. + fn client_services(&self) -> bool { + false + } + + fn owned_config_options(&self) -> &'static [&'static str] { + &[MODEL_CONFIG_ID] + } + + fn auto_approves(&self, tool_call: &acp::ToolCallUpdate) -> bool { + let Some(kind) = tool_call.fields.kind else { + return false; + }; + match self.approval_mode { + // A web fetch changes nothing locally but can carry data out, so + // it is asked about like any other action. + ApprovalMode::ReadOnly => matches!(kind, acp::ToolKind::Read | acp::ToolKind::Search), + ApprovalMode::AutoAcceptEdits => matches!( + kind, + acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move + ), + ApprovalMode::Supervised | ApprovalMode::FullAccess => false, + } + } +} + +fn start_failure(err: &acp::Error, context: &str) -> AgentError { + if is_auth_required(err) { + signed_out() + } else { + AgentError::Protocol(format!("Cursor {context}: {}", describe(err))) + } +} + +/// Put the session on the composer's model, returning Cursor's options for +/// it. Cursor resumes a loaded conversation on the model it last used, so +/// this follows `session/load` as well as `session/new`. +async fn select_model( + setup: &Setup<'_>, + session_id: &acp::SessionId, + model: &str, + config_options: &[acp::SessionConfigOption], +) -> Result>, AgentError> { + let current = config_options + .iter() + .find(|option| option.id.0.as_ref() == MODEL_CONFIG_ID) + .and_then(select_value); + if current == Some(model) { + return Ok(None); + } + let selected = set_config_option(setup, session_id, MODEL_CONFIG_ID, model) + .await + .map_err(|err| { + AgentError::Provider(format!( + "Cursor did not accept the model `{model}`: {}", + describe(&err) + )) + })?; + Ok(Some(selected)) +} + +/// Re-select the model parameters this session chose before: a new process +/// starts on the parameters Cursor last saved for the model, which another +/// session may have changed. A choice the model no longer offers is dropped. +async fn restore_parameters( + setup: &Setup<'_>, + session_id: &acp::SessionId, + config_options: &[acp::SessionConfigOption], +) { + for option in config_options { + let id = option.id.0.as_ref(); + let acp::SessionConfigKind::Select(select) = &option.kind else { + continue; + }; + let Some(chosen) = acp_session::config_selection(&setup.opts.option_selections, id) else { + continue; + }; + let offered = match &select.options { + acp::SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .any(|option| option.value.0.as_ref() == chosen), + acp::SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| &group.options) + .any(|option| option.value.0.as_ref() == chosen), + _ => false, + }; + if id == MODEL_CONFIG_ID || select_value(option) == Some(chosen) || !offered { + continue; + } + if let Err(err) = set_config_option(setup, session_id, id, chosen).await { + setup + .warn(format!( + "Cursor did not restore {} `{chosen}`: {}", + option.name, + describe(&err) + )) + .await; + } + } +} + +fn select_value(option: &acp::SessionConfigOption) -> Option<&str> { + match &option.kind { + acp::SessionConfigKind::Select(select) => Some(select.current_value.0.as_ref()), + _ => None, + } +} + +/// `session/set_config_option`, adopting the refreshed options it answers +/// with. +async fn set_config_option( + setup: &Setup<'_>, + session_id: &acp::SessionId, + config_id: &str, + value: &str, +) -> Result, acp::Error> { + let response = setup + .connection + .send_request(acp::SetSessionConfigOptionRequest::new( + session_id.clone(), + acp::SessionConfigId::new(config_id), + acp::SessionConfigOptionValue::value_id(acp::SessionConfigValueId::new(value)), + )) + .block_task() + .await?; + setup.adopt(None, Some(&response.config_options)); + Ok(response.config_options) +} + +/// The questions of a `cursor/ask_question` request. Canonical answers are +/// option labels; Cursor takes option ids. +struct Questions { + title: String, + questions: Vec, +} + +struct Question { + id: String, + prompt: String, + /// `(label, id)` in Cursor's order. + options: Vec<(String, String)>, + allow_multiple: bool, +} + +impl Questions { + fn parse(params: &Value) -> Result { + let questions = params + .get("questions") + .and_then(Value::as_array) + .ok_or_else(acp::Error::invalid_params)?; + Ok(Self { + title: params + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + questions: questions + .iter() + .filter_map(|question| { + Some(Question { + id: question.get("id")?.as_str()?.to_string(), + prompt: question.get("prompt")?.as_str()?.to_string(), + options: question + .get("options") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|option| { + Some(( + option.get("label")?.as_str()?.to_string(), + option.get("id")?.as_str()?.to_string(), + )) + }) + .collect(), + allow_multiple: question + .get("allowMultiple") + .and_then(Value::as_bool) + .unwrap_or(false), + }) + }) + .collect(), + }) + } + + fn canonical(&self) -> Vec { + self.questions + .iter() + .map(|question| UserInputQuestion { + id: question.id.clone(), + header: self.title.clone(), + question: question.prompt.clone(), + options: question + .options + .iter() + .map(|(label, _)| UserInputOption { + label: label.clone(), + description: String::new(), + }) + .collect(), + multi_select: question.allow_multiple, + prefill: None, + }) + .collect() + } + + /// The reply to Cursor, and the prompts whose typed answer matched no + /// option (Cursor's answers carry option ids only). `None` is a request + /// cancelled by an interrupt or shutdown. + fn reply(&self, answers: Option>) -> (Value, Vec) { + let Some(answers) = answers else { + return (json!({ "outcome": { "outcome": "cancelled" } }), Vec::new()); + }; + let mut unsent = Vec::new(); + let answered: Vec = self + .questions + .iter() + .filter_map(|question| { + let labels: Vec<&str> = match answers.get(&question.id)? { + Value::String(label) => vec![label.as_str()], + Value::Array(labels) => labels.iter().filter_map(Value::as_str).collect(), + _ => Vec::new(), + }; + let mut selected = Vec::new(); + for label in labels.into_iter().filter(|label| !label.trim().is_empty()) { + match question.options.iter().find(|(known, _)| known == label) { + Some((_, id)) => selected.push(id.as_str()), + None => unsent.push(format!("“{}”", question.prompt)), + } + } + (!selected.is_empty()) + .then(|| json!({ "questionId": question.id, "selectedOptionIds": selected })) + }) + .collect(); + let reply = if answered.is_empty() { + json!({ "outcome": { "outcome": "skipped" } }) + } else { + json!({ "outcome": { "outcome": "answered", "answers": answered } }) + }; + (reply, unsent) + } +} + +/// One entry of Cursor's todo list. +#[derive(Clone)] +struct Todo { + id: String, + content: String, + status: String, +} + +impl Todo { + fn parse(todo: &Value) -> Option { + Some(Self { + id: todo.get("id")?.as_str()?.to_string(), + content: todo.get("content")?.as_str()?.to_string(), + status: todo + .get("status") + .and_then(Value::as_str) + .unwrap_or("pending") + .to_string(), + }) + } + + /// A cancelled todo is no longer part of the plan; the canonical plan + /// has no cancelled step. + fn step(&self) -> Option { + let status = match self.status.as_str() { + "in_progress" => PlanStepStatus::InProgress, + "completed" => PlanStepStatus::Completed, + "cancelled" => return None, + _ => PlanStepStatus::Pending, + }; + Some(PlanStep { + step: self.content.clone(), + status, + }) + } +} diff --git a/crates/agent/src/cursor/tests.rs b/crates/agent/src/cursor/tests.rs new file mode 100644 index 000000000..7cd093e78 --- /dev/null +++ b/crates/agent/src/cursor/tests.rs @@ -0,0 +1,956 @@ +//! The Cursor dialect driven as the app drives it: [`super::start`] and +//! [`super::list_models`] spawn a stand-in `cursor-agent` that relays its +//! stdio to the test, which answers as Cursor's ACP server does from +//! `tests/fixtures/cursor` (see its `PROVENANCE.md`). + +use std::collections::VecDeque; +use std::path::PathBuf; +use std::time::Duration; + +use serde_json::{Map, Value, json}; +use smol::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; +use smol::net::{TcpListener, TcpStream}; + +use crate::{ + AgentEvent, ApprovalDecision, ApprovalKind, ApprovalMode, InteractionMode, ItemContent, + ItemStatus, LaunchEnv, OptionSelection, PlanStepStatus, ResumeCursor, SessionCommand, + SessionHandle, SessionOptions, ThreadItem, TurnStatus, +}; + +const RECORDED: &str = include_str!("../../tests/fixtures/cursor/recorded_signed_out.jsonl"); +const SOURCE: &str = include_str!("../../tests/fixtures/cursor/source_derived.json"); +const SESSION: &str = "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10"; + +fn source(name: &str) -> Value { + let fixtures: Value = serde_json::from_str(SOURCE).unwrap(); + let value = fixtures[name].clone(); + assert!(!value.is_null(), "no source-derived fixture {name}"); + value +} + +/// A line `cursor-agent` 2026.10.01-e373342 wrote while signed out. +fn recorded(line: usize) -> Value { + serde_json::from_str(RECORDED.lines().nth(line).unwrap()).unwrap() +} + +fn recorded_initialize() -> Value { + recorded(0)["result"].clone() +} + +fn recorded_auth_required() -> Value { + let error = recorded(2)["error"].clone(); + assert_eq!(recorded(1)["error"], error); + error +} + +/// Fails a test that would otherwise wait forever on a broken exchange. +async fn bounded(future: impl Future) -> T { + smol::future::or(future, async { + smol::Timer::after(Duration::from_secs(30)).await; + panic!("the Cursor exchange stalled"); + }) + .await +} + +fn stand_in() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cursor/cursor-agent") +} + +/// The far end of the stand-in: Cursor's ACP server, as this test plays it. +struct Agent { + listener: TcpListener, + lines: Option>, + out: Option, + /// The stand-in's arguments. + argv: String, + /// Client messages read while waiting for something else. + pending: VecDeque, + /// Every method the client sent. + methods: Vec, + request_seq: u64, +} + +impl Agent { + async fn new() -> Self { + Self { + listener: TcpListener::bind("127.0.0.1:0").await.unwrap(), + lines: None, + out: None, + argv: String::new(), + pending: VecDeque::new(), + methods: Vec::new(), + request_seq: 0, + } + } + + /// The launch environment that points the stand-in at this test. + fn launch_env(&self) -> LaunchEnv { + let port = self.listener.local_addr().unwrap().port(); + LaunchEnv { + env: vec![("TCODE_STAND_IN_PORT".into(), port.to_string())], + home: None, + } + } + + fn options(&self) -> SessionOptions { + SessionOptions { + cwd: std::env::temp_dir(), + model: None, + resume: None, + fork: false, + binary_path: Some(stand_in()), + approval_mode: ApprovalMode::Supervised, + option_selections: Vec::new(), + interaction_mode: InteractionMode::Build, + mcp_servers: Vec::new(), + launch_env: self.launch_env(), + extra_args: Vec::new(), + acp: None, + } + } + + /// Take the stand-in's connection, then answer `initialize` as the + /// signed-out binary did. + async fn connect(&mut self, client: &mut smol::Task>) { + let (stream, _) = + smol::future::or(async { self.listener.accept().await.unwrap() }, async { + let error = client.await.err(); + panic!("the client ended before the stand-in connected: {error:?}") + }) + .await; + self.out = Some(stream.clone()); + let mut lines = BufReader::new(stream); + let mut argv = String::new(); + lines.read_line(&mut argv).await.unwrap(); + self.argv = argv.trim_end().to_string(); + self.lines = Some(lines); + let initialize = self.expect("initialize").await; + assert_eq!( + initialize["params"]["clientCapabilities"]["_meta"], + json!({ "parameterizedModelPicker": true, "subagents": true }) + ); + self.reply(&initialize, recorded_initialize()).await; + } + + async fn read(&mut self) -> Value { + let mut line = String::new(); + let lines = self.lines.as_mut().unwrap(); + assert!( + lines.read_line(&mut line).await.unwrap() > 0, + "the client closed the connection" + ); + let message: Value = serde_json::from_str(&line).unwrap(); + if let Some(method) = message.get("method").and_then(Value::as_str) { + self.methods.push(method.to_string()); + } + message + } + + async fn recv(&mut self) -> Value { + match self.pending.pop_front() { + Some(message) => message, + None => self.read().await, + } + } + + async fn expect(&mut self, method: &str) -> Value { + let message = self.recv().await; + assert_eq!(message["method"], method, "{message}"); + message + } + + async fn send(&mut self, message: Value) { + self.send_all([message]).await; + } + + /// Messages in one write, so the client reads them together. + async fn send_all(&mut self, messages: [Value; N]) { + let mut lines = String::new(); + for message in messages { + lines.push_str(&message.to_string()); + lines.push('\n'); + } + let out = self.out.as_mut().unwrap(); + out.write_all(lines.as_bytes()).await.unwrap(); + out.flush().await.unwrap(); + } + + async fn reply(&mut self, request: &Value, result: Value) { + self.send(json!({ "jsonrpc": "2.0", "id": request["id"], "result": result })) + .await; + } + + async fn fail(&mut self, request: &Value, error: Value) { + self.send(json!({ "jsonrpc": "2.0", "id": request["id"], "error": error })) + .await; + } + + async fn update(&mut self, session: &str, update: Value) { + self.send(json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { "sessionId": session, "update": update }, + })) + .await; + } + + /// Send an agent→client request without waiting for its answer. + async fn request(&mut self, method: &str, params: Value) -> String { + self.request_seq += 1; + let id = format!("agent-{}", self.request_seq); + self.send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) + .await; + id + } + + /// The client's answer to request `id`, leaving other messages queued. + async fn answer(&mut self, id: &str) -> Value { + if let Some(position) = self.pending.iter().position(|message| message["id"] == id) { + return self.pending.remove(position).unwrap(); + } + loop { + let message = self.read().await; + if message["id"] == id && message.get("method").is_none() { + return message; + } + self.pending.push_back(message); + } + } + + async fn new_session(&mut self) -> Value { + let new = self.expect("session/new").await; + self.reply(&new, source("session_new")).await; + new + } + + /// Shut the session down, then wait until every stand-in process is + /// gone (each holds its end of the socket until it exits). Returns every + /// method the client sent. + async fn finish(mut self, handle: Option) -> Vec { + if let Some(handle) = handle { + let _ = handle.commands.send(SessionCommand::Shutdown).await; + while handle.events.recv().await.is_ok() {} + } + if let Some(out) = self.out.take() { + out.shutdown(std::net::Shutdown::Write).unwrap(); + } + if let Some(mut lines) = self.lines.take() { + let mut rest = String::new(); + lines.read_to_string(&mut rest).await.unwrap(); + for line in rest.lines() { + let message: Value = serde_json::from_str(line).unwrap(); + if let Some(method) = message.get("method").and_then(Value::as_str) { + self.methods.push(method.to_string()); + } + } + } + std::mem::take(&mut self.methods) + } + + async fn start(&mut self, opts: SessionOptions) -> SessionHandle { + let mut starting = smol::spawn(super::start(opts)); + self.connect(&mut starting).await; + self.new_session().await; + starting.await.unwrap() + } +} + +async fn send_turn(handle: &SessionHandle, text: &str) { + handle + .commands + .send(SessionCommand::SendTurn { + delivery_id: 1, + text: text.to_string(), + options: None, + attachments: Vec::new(), + }) + .await + .unwrap(); +} + +/// Events up to and including the first that `last` accepts. +async fn events_until( + handle: &SessionHandle, + last: impl Fn(&AgentEvent) -> bool, +) -> Vec { + let mut events = Vec::new(); + loop { + let event = handle.events.recv().await.unwrap(); + let done = last(&event); + events.push(event); + if done { + return events; + } + } +} + +fn turn_completed(event: &AgentEvent) -> bool { + matches!(event, AgentEvent::TurnCompleted { .. }) +} + +fn items(events: &[AgentEvent]) -> Vec<&ThreadItem> { + events + .iter() + .filter_map(|event| match event { + AgentEvent::ItemStarted(item) + | AgentEvent::ItemUpdated(item) + | AgentEvent::ItemCompleted(item) => Some(item), + _ => None, + }) + .collect() +} + +/// The last state of each item, by id. +fn last_item<'a>(events: &'a [AgentEvent], id: &str) -> &'a ThreadItem { + items(events) + .into_iter() + .rev() + .find(|item| item.id == id) + .unwrap_or_else(|| panic!("no item {id} in {events:#?}")) +} + +fn turn_status(events: &[AgentEvent]) -> TurnStatus { + events + .iter() + .find_map(|event| match event { + AgentEvent::TurnCompleted { status, usage, .. } => { + assert_eq!(*usage, None, "Cursor reports no usage"); + Some(*status) + } + _ => None, + }) + .unwrap() +} + +#[test] +fn signed_out_cursor_fails_fast_with_sign_in_guidance() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let mut starting = smol::spawn(super::start(agent.options())); + agent.connect(&mut starting).await; + let new = agent.expect("session/new").await; + agent.fail(&new, recorded_auth_required()).await; + let error = starting.await.err().expect("a signed-out start fails"); + assert!( + matches!(&error, crate::AgentError::Provider(message) if message.contains("Cursor is not signed in") && message.contains("cursor-agent login")), + "{error}" + ); + + let mut catalog = Agent::new().await; + let mut listing = smol::spawn(super::list_models(Some(stand_in()), catalog.launch_env())); + catalog.connect(&mut listing).await; + let list = catalog.expect("cursor/list_available_models").await; + catalog.fail(&list, recorded_auth_required()).await; + let error = listing.await.expect_err("a signed-out catalog fails"); + assert!( + error.to_string().contains("Cursor is not signed in"), + "{error}" + ); + + for methods in [agent.finish(None).await, catalog.finish(None).await] { + assert!( + !methods.iter().any(|method| method == "authenticate"), + "`authenticate` starts a browser login: {methods:?}" + ); + } + })); +} + +#[test] +fn catalog_ids_are_the_model_values_cursor_accepts() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let mut listing = smol::spawn(super::list_models(Some(stand_in()), agent.launch_env())); + agent.connect(&mut listing).await; + let list = agent.expect("cursor/list_available_models").await; + agent.reply(&list, source("list_available_models")).await; + let models = listing.await.unwrap(); + let ids: Vec<_> = models + .iter() + .map(|model| (model.id.as_str(), model.display_name.as_str())) + .collect(); + assert_eq!(ids, [("composer-1", "Composer 1"), ("gpt-5", "GPT-5")]); + agent.finish(None).await; + })); +} + +#[test] +fn the_composers_model_and_the_sessions_parameters_are_selected_at_start() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.model = Some("gpt-5".into()); + opts.option_selections = [("acp:cfg:reasoning", "high"), ("acp:cfg:fast", "true")] + .map(|(id, value)| OptionSelection { + id: id.into(), + value: json!(value), + }) + .into(); + let mut starting = smol::spawn(super::start(opts)); + agent.connect(&mut starting).await; + agent.new_session().await; + let select = agent.expect("session/set_config_option").await; + assert_eq!( + select["params"], + json!({ "sessionId": SESSION, "configId": "model", "value": "gpt-5" }) + ); + agent.reply(&select, source("set_model_gpt5")).await; + // The new model has no `fast`: only its own parameter is restored. + let restore = agent.expect("session/set_config_option").await; + assert_eq!( + restore["params"], + json!({ "sessionId": SESSION, "configId": "reasoning", "value": "high" }) + ); + agent.reply(&restore, source("set_reasoning_high")).await; + let handle = starting.await.unwrap(); + let events = events_until(&handle, |event| { + matches!(event, AgentEvent::ProviderOptions { .. }) + }) + .await; + let Some(AgentEvent::SessionStarted { model, .. }) = events.first() else { + panic!("{events:#?}"); + }; + assert_eq!(model.as_deref(), Some("gpt-5")); + let Some(AgentEvent::ProviderOptions { selections, .. }) = events.last() else { + unreachable!(); + }; + let selections: Vec<_> = selections + .iter() + .map(|selection| (selection.id.as_str(), selection.value.as_str().unwrap())) + .collect(); + assert_eq!( + selections, + [("acp:mode", "agent"), ("acp:cfg:reasoning", "high")], + "the model is the composer's, and the last model's parameter is gone" + ); + agent.finish(Some(handle)).await; + + // Already on the model: nothing to select. + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.model = Some("composer-1".into()); + let handle = agent.start(opts).await; + let methods = agent.finish(Some(handle)).await; + assert!( + !methods + .iter() + .any(|method| method == "session/set_config_option"), + "{methods:?}" + ); + })); +} + +#[test] +fn tool_results_are_judged_by_their_raw_output_and_cursors_failure_text_fails_the_turn() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let handle = agent.start(agent.options()).await; + + send_turn(&handle, "Fix the build").await; + let prompt = agent.expect("session/prompt").await; + for update in source("turn_tools").as_array().unwrap().clone() { + agent.update(SESSION, update).await; + } + agent + .reply(&prompt, json!({ "stopReason": "end_turn" })) + .await; + let events = events_until(&handle, turn_completed).await; + + match &last_item(&events, "call_shell_1").content { + ItemContent::CommandExecution { + command, + output, + exit_code, + status, + } => { + assert_eq!(command, "cargo build"); + assert_eq!(output, "error[E0425]: cannot find value `x`"); + assert_eq!(*exit_code, Some(101)); + assert_eq!(*status, ItemStatus::Failed); + } + other => panic!("{other:?}"), + } + match &last_item(&events, "call_edit_1").content { + ItemContent::FileChange { changes, status } => { + assert_eq!(*status, ItemStatus::Completed); + assert_eq!(changes[0].path, "src/lib.rs"); + } + other => panic!("{other:?}"), + } + match &last_item(&events, "call_mcp_1").content { + ItemContent::ToolCall { status, .. } => assert_eq!(*status, ItemStatus::Declined), + other => panic!("{other:?}"), + } + match &last_item(&events, "call_grep_1").content { + ItemContent::ToolCall { status, .. } => assert_eq!(*status, ItemStatus::Completed), + other => panic!("{other:?}"), + } + // Prose that mentions an error is the model's, not a failed run. + assert_eq!(turn_status(&events), TurnStatus::Completed); + + send_turn(&handle, "Try again").await; + let prompt = agent.expect("session/prompt").await; + agent + .update( + SESSION, + json!({ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "Retrying." } }), + ) + .await; + agent.update(SESSION, source("run_error")).await; + agent + .reply(&prompt, json!({ "stopReason": "end_turn" })) + .await; + let events = events_until(&handle, turn_completed).await; + assert_eq!(turn_status(&events), TurnStatus::Failed); + let text: String = events + .iter() + .filter_map(|event| match event { + AgentEvent::ItemCompleted(ThreadItem { + content: ItemContent::AssistantMessage { text }, + .. + }) => Some(text.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + text, + "Retrying.\n\nError: ConnectError: [unavailable] upstream connect error" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, AgentEvent::Error { .. })), + "the failure is already in the transcript: {events:#?}" + ); + agent.finish(Some(handle)).await; + })); +} + +#[test] +fn questions_and_plans_are_answered_in_cursors_nested_shapes() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let handle = agent.start(agent.options()).await; + send_turn(&handle, "Plan the storage move").await; + let prompt = agent.expect("session/prompt").await; + + let asked = agent + .request("cursor/ask_question", source("ask_question")) + .await; + let events = events_until(&handle, |event| { + matches!(event, AgentEvent::UserInputRequested { .. }) + }) + .await; + let Some(AgentEvent::UserInputRequested { + request_id, + questions, + .. + }) = events.last() + else { + unreachable!(); + }; + let asked_questions: Vec<_> = questions + .iter() + .map(|question| { + let labels: Vec<_> = question + .options + .iter() + .map(|option| option.label.as_str()) + .collect(); + ( + question.id.as_str(), + question.question.as_str(), + labels, + question.multi_select, + ) + }) + .collect(); + assert_eq!( + asked_questions, + [ + ("db", "Which database?", vec!["Postgres", "SQLite"], false), + ( + "extras", + "Which extras?", + vec!["Cache", "Queue", "Search"], + true + ), + ] + ); + let mut answers = Map::new(); + answers.insert("db".into(), json!("Postgres")); + answers.insert("extras".into(), json!(["Cache", "Search"])); + handle + .commands + .send(SessionCommand::RespondUserInput { + request_id: request_id.clone(), + answers, + }) + .await + .unwrap(); + assert_eq!( + agent.answer(&asked).await["result"], + json!({ "outcome": { + "outcome": "answered", + "answers": [ + { "questionId": "db", "selectedOptionIds": ["pg"] }, + { "questionId": "extras", "selectedOptionIds": ["cache", "search"] }, + ], + } }) + ); + + agent.update(SESSION, source("create_plan_entries")).await; + let planned = agent + .request("cursor/create_plan", source("create_plan")) + .await; + assert_eq!( + agent.answer(&planned).await["result"], + json!({ "outcome": { "outcome": "accepted" } }) + ); + let events = events_until(&handle, |event| { + matches!(event, AgentEvent::ProposedPlan { .. }) + }) + .await; + assert!(matches!( + events.last(), + Some(AgentEvent::ProposedPlan { item_id, markdown }) + if item_id == "call_plan_1" && markdown == "# Storage\n\n1. Add the migration\n2. Wire the repository\n" + )); + + let todos = agent + .request("cursor/update_todos", source("update_todos")) + .await; + assert_eq!(agent.answer(&todos).await["result"], json!({})); + let merged = agent + .request("cursor/update_todos", source("update_todos_merge")) + .await; + assert_eq!(agent.answer(&merged).await["result"], json!({})); + let task = agent.request("cursor/task", source("task")).await; + assert_eq!(agent.answer(&task).await["result"], json!({})); + agent + .reply(&prompt, json!({ "stopReason": "end_turn" })) + .await; + + let events = events_until(&handle, turn_completed).await; + let plans: Vec> = events + .iter() + .filter_map(|event| match event { + AgentEvent::PlanUpdated { steps, .. } => Some( + steps + .iter() + .map(|step| (step.step.as_str(), step.status)) + .collect(), + ), + _ => None, + }) + .collect(); + assert_eq!( + plans.last().unwrap(), + &[ + ("Add the migration", PlanStepStatus::Completed), + ("Wire the repository", PlanStepStatus::Pending), + ], + "a merge updates by id and a cancelled todo leaves the plan: {plans:?}" + ); + assert_eq!(turn_status(&events), TurnStatus::Completed); + agent.finish(Some(handle)).await; + })); +} + +#[test] +fn an_interrupt_cancels_a_pending_question() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let handle = agent.start(agent.options()).await; + send_turn(&handle, "Plan the storage move").await; + let prompt = agent.expect("session/prompt").await; + let asked = agent + .request("cursor/ask_question", source("ask_question")) + .await; + events_until(&handle, |event| { + matches!(event, AgentEvent::UserInputRequested { .. }) + }) + .await; + handle + .commands + .send(SessionCommand::Interrupt) + .await + .unwrap(); + assert_eq!( + agent.answer(&asked).await["result"], + json!({ "outcome": { "outcome": "cancelled" } }) + ); + let cancel = agent.expect("session/cancel").await; + assert_eq!(cancel["params"]["sessionId"], SESSION); + agent + .reply(&prompt, json!({ "stopReason": "cancelled" })) + .await; + let events = events_until(&handle, turn_completed).await; + assert_eq!(turn_status(&events), TurnStatus::Interrupted); + agent.finish(Some(handle)).await; + })); +} + +#[test] +fn permissions_are_granted_once_by_kind_and_otherwise_asked() { + smol::block_on(bounded(async { + for (mode, granted, asked) in [ + (ApprovalMode::ReadOnly, "permission_read", "permission_edit"), + ( + ApprovalMode::AutoAcceptEdits, + "permission_edit", + "permission_shell", + ), + (ApprovalMode::Supervised, "", "permission_read"), + ] { + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.approval_mode = mode; + let handle = agent.start(opts).await; + assert_eq!(agent.argv, "acp", "{mode:?}"); + send_turn(&handle, "Go").await; + let prompt = agent.expect("session/prompt").await; + + if !granted.is_empty() { + let mut params = source(granted); + params["sessionId"] = json!(SESSION); + let id = agent.request("session/request_permission", params).await; + assert_eq!( + agent.answer(&id).await["result"], + json!({ "outcome": { "outcome": "selected", "optionId": "allow-once" } }), + "{mode:?}" + ); + } + + let mut params = source(asked); + params["sessionId"] = json!(SESSION); + let id = agent.request("session/request_permission", params).await; + let events = events_until(&handle, |event| { + matches!(event, AgentEvent::ApprovalRequested(_)) + }) + .await; + let approvals: Vec<_> = events + .iter() + .filter_map(|event| match event { + AgentEvent::ApprovalRequested(request) => Some(request), + _ => None, + }) + .collect(); + assert_eq!( + approvals.len(), + 1, + "{mode:?}: only {asked} is asked: {events:#?}" + ); + let request = approvals[0]; + let expected_kind = match asked { + "permission_edit" => matches!(request.kind, ApprovalKind::FileChange { .. }), + "permission_shell" => matches!(request.kind, ApprovalKind::ExecCommand { .. }), + _ => matches!(request.kind, ApprovalKind::FileRead { .. }), + }; + assert!(expected_kind, "{mode:?}: {request:?}"); + handle + .commands + .send(SessionCommand::RespondApproval { + request_id: request.id.clone(), + decision: ApprovalDecision::ApproveForSession, + }) + .await + .unwrap(); + assert_eq!( + agent.answer(&id).await["result"], + json!({ "outcome": { "outcome": "selected", "optionId": "allow-once" } }), + "{mode:?}: a session-wide approval never writes Cursor's allowlist" + ); + agent + .reply(&prompt, json!({ "stopReason": "end_turn" })) + .await; + events_until(&handle, turn_completed).await; + agent.finish(Some(handle)).await; + } + + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.approval_mode = ApprovalMode::FullAccess; + opts.extra_args = vec!["--sandbox".into(), "disabled".into()]; + let handle = agent.start(opts).await; + assert_eq!(agent.argv, "--force --sandbox disabled acp"); + agent.finish(Some(handle)).await; + })); +} + +#[test] +fn a_loaded_session_replays_nothing_and_a_failed_load_is_an_error() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.resume = Some(ResumeCursor(json!({ "session_id": SESSION }))); + let cwd = opts.cwd.clone(); + let mut starting = smol::spawn(super::start(opts)); + agent.connect(&mut starting).await; + let load = agent.expect("session/load").await; + assert_eq!( + load["params"], + json!({ "sessionId": SESSION, "cwd": cwd, "mcpServers": [] }) + ); + for update in source("load_replay").as_array().unwrap().clone() { + agent.update(SESSION, update).await; + } + // A blocking request mid-replay is answered without the user. + let asked = agent + .request("cursor/ask_question", source("ask_question")) + .await; + assert_eq!( + agent.answer(&asked).await["result"], + json!({ "outcome": { "outcome": "cancelled" } }) + ); + // Cursor sends its slash commands as soon as the load has answered. + agent + .send_all([ + json!({ "jsonrpc": "2.0", "id": load["id"], "result": source("session_load") }), + json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { "sessionId": SESSION, "update": source("available_commands") }, + }), + ]) + .await; + let handle = starting.await.unwrap(); + let events = events_until(&handle, |event| { + matches!(event, AgentEvent::ProviderCommands { .. }) + }) + .await; + assert!( + events.iter().all(|event| matches!( + event, + AgentEvent::SessionStarted { .. } + | AgentEvent::ProviderOptions { .. } + | AgentEvent::ProviderCommands { .. } + )), + "{events:#?}" + ); + let Some(AgentEvent::SessionStarted { resume, .. }) = events.first() else { + panic!("{events:#?}"); + }; + assert_eq!(resume.0, json!({ "session_id": SESSION })); + agent.finish(Some(handle)).await; + + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.resume = Some(ResumeCursor(json!({ "session_id": "gone" }))); + let mut starting = smol::spawn(super::start(opts)); + agent.connect(&mut starting).await; + let load = agent.expect("session/load").await; + agent + .fail( + &load, + json!({ "code": -32602, "message": "Invalid params", "data": { "message": "Session \"gone\" not found" } }), + ) + .await; + let error = starting.await.err().expect("a failed load fails the start"); + assert!( + error.to_string().contains("could not resume session gone"), + "{error}" + ); + let methods = agent.finish(None).await; + assert!( + !methods.iter().any(|method| method == "session/new"), + "never a fresh session in its place: {methods:?}" + ); + })); +} + +#[test] +fn subagent_sessions_become_subagent_items_with_their_own_activity() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let handle = agent.start(agent.options()).await; + send_turn(&handle, "Survey the schema").await; + let prompt = agent.expect("session/prompt").await; + for notification in source("subagent_turn").as_array().unwrap().clone() { + agent + .update( + notification["sessionId"].as_str().unwrap(), + notification["update"].clone(), + ) + .await; + } + agent + .reply(&prompt, json!({ "stopReason": "end_turn" })) + .await; + let events = events_until(&handle, turn_completed).await; + + let child = "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4"; + let mut subagent = Vec::new(); + let mut children = Vec::new(); + for event in &events { + match event { + AgentEvent::ItemStarted(item) + | AgentEvent::ItemUpdated(item) + | AgentEvent::ItemCompleted(item) => match (&item.content, &item.parent_item_id) { + ( + ItemContent::Subagent { + agent_type, + description, + status, + model, + .. + }, + None, + ) => { + assert_eq!(item.id, "call_task_1"); + assert_eq!( + (agent_type.as_str(), description.as_str()), + ("explore", "Survey the schema") + ); + subagent.push((*status, model.clone())); + } + (content, Some(parent)) => { + assert_eq!(parent, "call_task_1"); + children.push((item.id.clone(), content.clone())); + } + (content, None) => assert!( + matches!(content, ItemContent::AssistantMessage { text } if text == "The schema has one table."), + "only the parent's own text stays in the parent: {content:?}" + ), + }, + AgentEvent::Delta { item_id, .. } => { + assert!( + !item_id.starts_with(child), + "a subagent's stream stays inside it" + ) + } + _ => {} + } + } + assert_eq!( + subagent.first(), + Some(&(ItemStatus::InProgress, None)), + "{subagent:?}" + ); + assert_eq!( + subagent.last(), + Some(&(ItemStatus::Completed, Some("composer-1".to_string()))), + "{subagent:?}" + ); + let texts: Vec<_> = children + .iter() + .filter_map(|(_, content)| match content { + ItemContent::AssistantMessage { text } => Some(text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["Looking at the migrations.", "One table: users."]); + assert!( + children.iter().any(|(id, content)| id == &format!("{child}/call_shell_1") + && matches!(content, ItemContent::CommandExecution { output, status: ItemStatus::Completed, .. } if output == "0001_init.sql\n")), + "{children:#?}" + ); + assert!( + children + .iter() + .all(|(id, _)| id.starts_with(&format!("{child}/"))), + "{children:#?}" + ); + agent.finish(Some(handle)).await; + })); +} diff --git a/crates/agent/src/cursor/updates.rs b/crates/agent/src/cursor/updates.rs new file mode 100644 index 000000000..6be1a75dd --- /dev/null +++ b/crates/agent/src/cursor/updates.rs @@ -0,0 +1,384 @@ +//! Cursor's `session/update`s on top of the standard mapping: tool results +//! judged by their `rawOutput`, and task tools with the subagent sessions +//! they run as canonical Subagent items and their children. + +use std::collections::HashMap; +use std::path::PathBuf; + +use agent_client_protocol::schema::v1 as acp; +use serde_json::Value; + +use crate::acp_session::State; +use crate::{AgentEvent, ItemContent, ItemStatus, ThreadItem}; + +/// One Cursor session and the subagent sessions under it. +pub(super) struct Updates { + cwd: PathBuf, + /// Subagent items by item id. + spawns: HashMap, + /// Subagent sessions by Cursor session id. + children: HashMap, +} + +struct Spawn { + parent_item_id: Option, + agent_type: String, + description: String, + model: Option, + status: ItemStatus, + /// `subagent_state_update` reported how the run ended; it outranks the + /// task tool's own completion. + settled: bool, + announced: bool, +} + +struct Child { + /// The Subagent item this session's activity belongs to. + spawn_item_id: String, + state: State, +} + +/// Where a subagent session's items go: ids namespaced by its Cursor session +/// id (a child numbers its text blocks from one as the parent does), parented +/// to its Subagent item. +struct Scope<'a> { + session: &'a str, + spawn_item_id: &'a str, +} + +impl Updates { + pub(super) fn new(cwd: PathBuf) -> Self { + Self { + cwd, + spawns: HashMap::new(), + children: HashMap::new(), + } + } + + pub(super) fn is_subagent(&self, session: &acp::SessionId) -> bool { + self.children.contains_key(session.0.as_ref()) + } + + pub(super) fn session_update( + &mut self, + state: &mut State, + notification: acp::SessionNotification, + ) -> Vec { + let mut update = notification.update; + let declined = judge_tool_result(&mut update); + let session = notification.session_id.0.to_string(); + match self.children.get_mut(&session) { + None => map(&mut self.spawns, None, state, update, declined), + Some(child) => { + let scope = Scope { + session: &session, + spawn_item_id: &child.spawn_item_id, + }; + map( + &mut self.spawns, + Some(&scope), + &mut child.state, + update, + declined, + ) + } + } + } + + /// `subagent_spawned` and `subagent_state_update`, which Cursor sends + /// when the client negotiates `_meta.subagents`. + pub(super) fn subagent_update(&mut self, params: &Value) -> Vec { + let (Some(session), Some(update)) = ( + params.get("sessionId").and_then(Value::as_str), + params.get("update"), + ) else { + return Vec::new(); + }; + let Some(subagent) = update.get("subagentSessionId").and_then(Value::as_str) else { + return Vec::new(); + }; + match update.get("sessionUpdate").and_then(Value::as_str) { + Some("subagent_spawned") => { + let meta = update.pointer("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/_meta/cursor"); + let Some(tool_call_id) = meta + .and_then(|meta| meta.get("toolCallId")) + .and_then(Value::as_str) + else { + return Vec::new(); + }; + let parent = self.children.get(session); + let scope = parent.map(|parent| Scope { + session, + spawn_item_id: &parent.spawn_item_id, + }); + let id = item_id(scope.as_ref(), tool_call_id); + let parent_item_id = scope.map(|scope| scope.spawn_item_id.to_string()); + let spawn = self + .spawns + .entry(id.clone()) + .or_insert_with(|| Spawn::new(parent_item_id)); + if spawn.agent_type.is_empty() + && let Some(name) = update.get("name").and_then(Value::as_str) + { + spawn.agent_type = name.to_string(); + } + if spawn.description.is_empty() + && let Some(task) = update.get("task").and_then(Value::as_str) + { + spawn.description = task.to_string(); + } + if let Some(model) = meta + .and_then(|meta| meta.get("model")) + .and_then(Value::as_str) + { + spawn.model = Some(model.to_string()); + } + let event = spawn.event(&id); + self.children.insert( + subagent.to_string(), + Child { + spawn_item_id: id, + state: State::new(self.cwd.clone()), + }, + ); + vec![event] + } + Some("subagent_state_update") => { + let status = match update.get("state").and_then(Value::as_str) { + Some("completed") => ItemStatus::Completed, + Some("failed") => ItemStatus::Failed, + Some("cancelled" | "disconnected") => ItemStatus::Interrupted, + _ => return Vec::new(), + }; + let Some(child) = self.children.get_mut(subagent) else { + return Vec::new(); + }; + let scope = Scope { + session: subagent, + spawn_item_id: &child.spawn_item_id, + }; + let mut events: Vec = child + .state + .flush_text() + .into_iter() + .filter_map(|event| child_event(&scope, event)) + .collect(); + if let Some(spawn) = self.spawns.get_mut(&child.spawn_item_id) { + spawn.status = status; + spawn.settled = true; + events.push(spawn.event(&child.spawn_item_id)); + } + events + } + _ => Vec::new(), + } + } +} + +/// One update of the session `scope` names (the session itself when `None`). +fn map( + spawns: &mut HashMap, + scope: Option<&Scope<'_>>, + state: &mut State, + update: acp::SessionUpdate, + declined: Option, +) -> Vec { + match &update { + acp::SessionUpdate::ToolCall(call) if is_task(call.raw_input.as_ref()) => { + let id = item_id(scope, &call.tool_call_id.0); + let spawn = spawns + .entry(id.clone()) + .or_insert_with(|| Spawn::new(scope.map(|scope| scope.spawn_item_id.to_string()))); + spawn.describe(call.raw_input.as_ref()); + return vec![spawn.event(&id)]; + } + acp::SessionUpdate::ToolCallUpdate(task) => { + let id = item_id(scope, &task.tool_call_id.0); + if let Some(spawn) = spawns.get_mut(&id) { + spawn.describe(task.fields.raw_input.as_ref()); + if !spawn.settled { + match task.fields.status { + Some(acp::ToolCallStatus::Failed) => spawn.status = ItemStatus::Failed, + // A background task returns while its subagent runs on. + Some(acp::ToolCallStatus::Completed) + if !task + .fields + .raw_output + .as_ref() + .and_then(|output| output.get("isBackground")) + .and_then(Value::as_bool) + .unwrap_or(false) => + { + spawn.status = ItemStatus::Completed; + } + _ => {} + } + } + return vec![spawn.event(&id)]; + } + } + _ => {} + } + let mut events = state.apply_update(update); + if let Some(tool) = declined { + for event in &mut events { + if let AgentEvent::ItemUpdated(item) | AgentEvent::ItemCompleted(item) = event + && item.id == tool + { + decline(&mut item.content); + } + } + } + match scope { + None => events, + Some(scope) => events + .into_iter() + .filter_map(|event| child_event(scope, event)) + .collect(), + } +} + +/// A subagent session's item, as a child of its Subagent item. Its streamed +/// text, plans, commands, options and usage stay inside it: the item +/// lifecycle is what reaches the subagent's own view. +fn child_event(scope: &Scope<'_>, event: AgentEvent) -> Option { + let adopt = |mut item: ThreadItem| { + item.id = item_id(Some(scope), &item.id); + item.parent_item_id = Some(scope.spawn_item_id.to_string()); + item + }; + match event { + AgentEvent::ItemStarted(item) => Some(AgentEvent::ItemStarted(adopt(item))), + AgentEvent::ItemUpdated(item) => Some(AgentEvent::ItemUpdated(adopt(item))), + AgentEvent::ItemCompleted(item) => Some(AgentEvent::ItemCompleted(adopt(item))), + _ => None, + } +} + +fn item_id(scope: Option<&Scope<'_>>, id: &str) -> String { + match scope { + None => id.to_string(), + Some(scope) => format!("{}/{id}", scope.session), + } +} + +/// Cursor's task tool, which runs a subagent: its `rawInput` names itself. +fn is_task(raw_input: Option<&Value>) -> bool { + raw_input + .and_then(|input| input.get("_toolName")) + .and_then(Value::as_str) + == Some("task") +} + +impl Spawn { + fn new(parent_item_id: Option) -> Self { + Self { + parent_item_id, + agent_type: String::new(), + description: String::new(), + model: None, + status: ItemStatus::InProgress, + settled: false, + announced: false, + } + } + + /// The task tool's `rawInput`: `{prompt, description, subagentType}`. + fn describe(&mut self, raw_input: Option<&Value>) { + let field = |key: &str| { + raw_input + .and_then(|input| input.get(key)) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + }; + if let Some(agent_type) = field("subagentType") { + self.agent_type = agent_type.to_string(); + } + if let Some(description) = field("description") { + self.description = description.to_string(); + } + } + + fn event(&mut self, id: &str) -> AgentEvent { + let item = ThreadItem { + id: id.to_string(), + parent_item_id: self.parent_item_id.clone(), + content: ItemContent::Subagent { + agent_type: if self.agent_type.is_empty() { + "subagent".to_string() + } else { + self.agent_type.clone() + }, + description: self.description.clone(), + status: self.status, + summary: None, + model: self.model.clone(), + effort: None, + }, + }; + if !std::mem::replace(&mut self.announced, true) { + AgentEvent::ItemStarted(item) + } else if self.status == ItemStatus::InProgress { + AgentEvent::ItemUpdated(item) + } else { + AgentEvent::ItemCompleted(item) + } + } +} + +/// Cursor reports every finished tool `completed`; how it went is only in +/// its `rawOutput`: a shell's `exitCode`, a tool's `error`, or a call the +/// user `rejected` or the permissions denied. Also gives a shell result its +/// output as text. Returns the id of a tool that was declined. +fn judge_tool_result(update: &mut acp::SessionUpdate) -> Option { + let acp::SessionUpdate::ToolCallUpdate(update) = update else { + return None; + }; + let fields = &mut update.fields; + let raw = fields.raw_output.as_ref()?; + if fields.content.is_none() + && let Some(output) = shell_output(raw) + { + fields.content = Some(vec![acp::ToolCallContent::from(acp::ContentBlock::from( + output, + ))]); + } + if fields.status != Some(acp::ToolCallStatus::Completed) { + return None; + } + let flag = |key: &str| raw.get(key).and_then(Value::as_bool) == Some(true); + if flag("rejected") || flag("permissionDenied") { + fields.status = Some(acp::ToolCallStatus::Failed); + return Some(update.tool_call_id.0.to_string()); + } + let errored = raw.get("error").is_some_and(|error| !error.is_null()); + let exit_code = raw.get("exitCode").and_then(Value::as_i64); + if errored || exit_code.is_some_and(|code| code != 0) { + fields.status = Some(acp::ToolCallStatus::Failed); + } + None +} + +/// A shell result's `stdout` and `stderr`, when it printed anything. +fn shell_output(raw: &Value) -> Option { + let stream = |key: &str| raw.get(key).and_then(Value::as_str); + if stream("stdout").is_none() && stream("stderr").is_none() { + return None; + } + let output = [stream("stdout"), stream("stderr")] + .into_iter() + .flatten() + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"); + (!output.is_empty()).then_some(output) +} + +fn decline(content: &mut ItemContent) { + match content { + ItemContent::CommandExecution { status, .. } + | ItemContent::FileChange { status, .. } + | ItemContent::ToolCall { status, .. } => *status = ItemStatus::Declined, + _ => {} + } +} diff --git a/crates/agent/src/grok.rs b/crates/agent/src/grok.rs new file mode 100644 index 000000000..b4db05dcf --- /dev/null +++ b/crates/agent/src/grok.rs @@ -0,0 +1,1430 @@ +//! Grok Build: `grok [--permission-mode M] agent [flags] stdio`, an ACP agent +//! with xAI's `_x.ai/*` dialect. +//! +//! The protocol machinery is [`crate::acp_session`]; this module is Grok's +//! dialect. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard}; + +use agent_client_protocol::{UntypedMessage, schema::v1 as acp}; +use serde_json::{Map, Value, json}; + +use crate::acp_session::{ + self, Dialect, Established, Launch, Session, Setup, State, TurnEnd, describe, is_auth_required, + prompt_status, stop_reason_status, +}; +use crate::{ + AgentError, AgentEvent, ApprovalMode, Attachment, Compaction, InteractionMode, ItemContent, + LaunchEnv, ModelSpec, ProviderKind, ResumeCursor, SessionHandle, SessionOptions, TokenUsage, + TurnStatus, UserInputOption, UserInputQuestion, +}; + +/// The non-interactive auth method: it validates `XAI_API_KEY` (or the key in +/// Grok's `config.toml`). The other advertised method signs in through a +/// browser and is never driven from here. +const API_KEY_AUTH_METHOD: &str = "xai.api_key"; + +const ASK_USER_QUESTION: &str = "_x.ai/ask_user_question"; +const EXIT_PLAN_MODE: &str = "_x.ai/exit_plan_mode"; +const INTERJECT: &str = "_x.ai/interject"; +const INTERJECTION: &str = "_x.ai/session/interjection"; +const QUEUE_CHANGED: &str = "_x.ai/queue/changed"; +const TASK_BACKGROUNDED: &str = "_x.ai/task_backgrounded"; +const SESSION_NOTIFICATION: &str = "_x.ai/session_notification"; +const FORK: &str = "_x.ai/session/fork"; + +/// Grok's session modes, by the ids `session/set_mode` takes and +/// `current_mode_update` reports. Grok does not list them in `modes`. +const DEFAULT_MODE: &str = "default"; +const PLAN_MODE: &str = "plan"; + +/// Start (or resume, or fork) a Grok session. +pub async fn start(opts: SessionOptions) -> Result { + let grok = Grok { + approval_mode: opts.approval_mode, + ..Grok::default() + }; + acp_session::start(ProviderKind::Grok, grok, opts).await +} + +/// The models Grok's `initialize` advertises. Their options arrive over the +/// wire once a session starts ([`crate::OptionDescriptors::Wire`]). +pub async fn list_models( + binary_path: Option, + launch_env: LaunchEnv, +) -> Result, AgentError> { + let opts = SessionOptions { + cwd: std::env::temp_dir(), + model: None, + resume: None, + fork: false, + binary_path, + approval_mode: ApprovalMode::default(), + option_selections: Vec::new(), + interaction_mode: InteractionMode::default(), + mcp_servers: Vec::new(), + launch_env, + extra_args: Vec::new(), + acp: None, + }; + acp_session::query( + ProviderKind::Grok, + Grok::default(), + opts, + async |_connection, init| Ok(catalog(init)), + ) + .await +} + +/// `initialize`'s `_meta.modelState`. +fn catalog(init: &acp::InitializeResponse) -> Vec { + let Some(state) = model_state(init) else { + return Vec::new(); + }; + let current = state.get("currentModelId").and_then(Value::as_str); + available_models(state) + .filter_map(|model| { + let id = model.get("modelId")?.as_str()?; + Some(ModelSpec { + id: id.to_string(), + display_name: model + .get("name") + .and_then(Value::as_str) + .unwrap_or(id) + .to_string(), + is_default: Some(id) == current, + options: Vec::new(), + }) + }) + .collect() +} + +fn model_state(init: &acp::InitializeResponse) -> Option<&Value> { + init.meta.as_ref()?.get("modelState") +} + +fn available_models(state: &Value) -> impl Iterator { + state + .get("availableModels") + .and_then(Value::as_array) + .into_iter() + .flatten() +} + +#[derive(Default)] +struct Grok { + approval_mode: ApprovalMode, + /// Steers sent with `_x.ai/interject`, oldest first, until Grok echoes + /// them back as consumed. + steers: Mutex>, + models: Mutex, + activity: Mutex, +} + +/// What Grok runs: its prompts, as `_x.ai/queue/changed` reports them, and +/// its background tasks. Grok runs prompts one at a time and starts some +/// itself: when a background task finishes it runs a prompt reporting it, +/// which no `session/prompt` answers. +#[derive(Default)] +struct Activity { + /// Prompts the client queued, until they run. + queued: HashSet, + /// The running prompt the client sent. + client: Option, + /// The running prompt Grok started itself; its turn has its id. + agent: Option, + /// Shell tool calls whose command went on in the background. Grok streams + /// their later output as running updates and never completes them again. + backgrounded: HashSet, + background_tasks: usize, + /// A drop to no background tasks, withheld while a sent turn is open: + /// Grok follows that turn with one reporting the finished task, and the + /// runtime must not take the process for idle in between. + drained: bool, +} + +impl Activity { + fn sent_turn_open(&self, state: &State) -> bool { + state.turn().is_some() && state.turn() != self.agent.as_deref() + } +} + +/// What usage reporting needs to know about the session's models. +#[derive(Default)] +struct Models { + /// `totalContextTokens` by model id. + windows: HashMap, + /// The model serving the session. + current: Option, + /// The latest model call's usage. + last_call: Option, +} + +impl Grok { + fn models(&self) -> MutexGuard<'_, Models> { + self.models + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn activity(&self) -> MutexGuard<'_, Activity> { + self.activity + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn steers(&self) -> MutexGuard<'_, VecDeque<(String, String)>> { + self.steers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// One model call's `response_completed` usage, whose `input_tokens` + /// excludes the cached prompt. + fn call_usage(&self, usage: &Value) -> TokenUsage { + let count = |key: &str| usage.get(key).and_then(Value::as_u64).unwrap_or(0); + let prompt = count("input_tokens") + + count("cache_read_input_tokens") + + count("cache_creation_input_tokens"); + let mut models = self.models(); + let usage = TokenUsage { + freshness: crate::ContextFreshness::Current, + input_tokens: Some(prompt), + cached_input_tokens: Some(count("cache_read_input_tokens")), + output_tokens: Some(count("output_tokens")), + used_tokens: Some(prompt + count("output_tokens")), + context_window: models.window(), + ..TokenUsage::default() + }; + models.last_call = Some(usage); + usage + } + + /// A turn's end as `turn_completed` reports it: its stop reason, and the + /// turn's usage on top of its last model call's. + fn reported_turn_end(&self, update: &Value) -> TurnEnd { + let (status, message) = match update.get("stop_reason").and_then(Value::as_str) { + Some("error") => ( + TurnStatus::Failed, + Some( + update + .get("agent_result") + .and_then(Value::as_str) + .unwrap_or("Grok's turn failed") + .to_string(), + ), + ), + Some(reason) => serde_json::from_value::(json!(reason)) + .map(stop_reason_status) + .unwrap_or_else(|_| { + ( + TurnStatus::Failed, + Some(format!("The agent stopped: {reason}")), + ) + }), + None => (TurnStatus::Completed, None), + }; + let models = self.models(); + let usage = update.get("usage").map(|usage| TokenUsage { + freshness: crate::ContextFreshness::Current, + turn_processed_tokens: usage.get("totalTokens").and_then(Value::as_u64), + context_window: models.window(), + ..models.last_call.unwrap_or_default() + }); + TurnEnd { + status, + message, + usage, + } + } + + /// A running prompt the client did not queue opens a turn of its own. + fn queue_changed(&self, state: &mut State, params: &Value) -> Vec { + let mut activity = self.activity(); + for entry in params + .get("entries") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let Some(id) = entry.get("id").and_then(Value::as_str) { + activity.queued.insert(id.to_string()); + } + } + let Some(running) = params.get("runningPromptId").and_then(Value::as_str) else { + return Vec::new(); + }; + if [&activity.client, &activity.agent] + .iter() + .any(|prompt| prompt.as_deref() == Some(running)) + { + return Vec::new(); + } + if activity.queued.remove(running) { + activity.client = Some(running.to_string()); + return Vec::new(); + } + activity.agent = Some(running.to_string()); + state.begin_agent_turn(running) + } + + /// `turn_completed` ends the turn of the prompt it names, in order with + /// everything else Grok sends; a sent turn's later `session/prompt` + /// result then completes nothing. + fn turn_completed(&self, state: &mut State, update: &Value) -> Vec { + let Some(prompt) = update.get("prompt_id").and_then(Value::as_str) else { + return Vec::new(); + }; + let end = self.reported_turn_end(update); + let mut activity = self.activity(); + if activity.agent.as_deref() == Some(prompt) { + activity.agent = None; + return state.end_turn(prompt, end); + } + if activity.client.as_deref() == Some(prompt) { + activity.client = None; + if let Some(turn) = state.turn().map(str::to_string) + && activity.sent_turn_open(state) + { + return state.end_turn(&turn, end); + } + } + Vec::new() + } + + /// The `session/prompt` result's `_meta`: the last model call's figures + /// (whose `totalTokens` is the context in use) and the turn's `usage`. + fn turn_usage(&self, meta: &acp::Meta) -> TokenUsage { + let count = |value: Option<&Value>| value.and_then(Value::as_u64); + let mut models = self.models(); + if let Some(model) = meta.get("modelId").and_then(Value::as_str) { + models.current = Some(model.to_string()); + } + TokenUsage { + freshness: crate::ContextFreshness::Current, + turn_processed_tokens: count( + meta.get("usage").and_then(|usage| usage.get("totalTokens")), + ), + input_tokens: count(meta.get("inputTokens")), + cached_input_tokens: count(meta.get("cachedReadTokens")), + output_tokens: count(meta.get("outputTokens")), + // `/compact` reports 0: nothing has been measured since. + used_tokens: count(meta.get("totalTokens")).filter(|tokens| *tokens > 0), + context_window: models.window(), + ..TokenUsage::default() + } + } + + /// One `_x.ai/session_notification` update. + fn session_notification(&self, state: &mut State, update: &Value) -> Vec { + let count = |key: &str| update.get(key).and_then(Value::as_u64); + match update.get("sessionUpdate").and_then(Value::as_str) { + Some("response_completed") => update + .get("usage") + .map(|usage| vec![AgentEvent::TokenUsage(self.call_usage(usage))]) + .unwrap_or_default(), + // A request that failed for good ends the turn, which reports it. + Some("retry_state") + if update.get("type").and_then(Value::as_str) == Some("retrying") => + { + vec![AgentEvent::Error { + message: retry_message(update), + fatal: false, + }] + } + Some("image_dropped") => { + let notes: Vec<&str> = update + .get("notes") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + if notes.is_empty() { + return Vec::new(); + } + vec![AgentEvent::Warning { + message: notes.join("\n"), + }] + } + Some("background_tasks") => { + let running = update + .get("tasks") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|task| task.get("status").and_then(Value::as_str) == Some("running")) + .count(); + let mut activity = self.activity(); + let drained = running == 0 && activity.background_tasks > 0; + activity.background_tasks = running; + activity.drained = drained && activity.sent_turn_open(state); + if activity.drained { + return Vec::new(); + } + vec![AgentEvent::BackgroundTasksChanged { count: running }] + } + Some("turn_completed") => self.turn_completed(state, update), + Some("auto_compact_started") => vec![AgentEvent::ContextCompacted(Compaction { + in_progress: true, + trigger: Some("auto".into()), + pre_tokens: count("tokens_used"), + ..Compaction::default() + })], + Some("auto_compact_completed") => vec![AgentEvent::ContextCompacted(Compaction { + in_progress: false, + pre_tokens: count("tokens_before"), + post_tokens: count("tokens_after"), + duration_ms: count("elapsed_ms"), + ..Compaction::default() + })], + _ => Vec::new(), + } + } + + fn steer_accepted(&self, params: &Value) -> Vec { + let text = params.get("text").and_then(Value::as_str); + let mut steers = self.steers(); + let Some(position) = steers + .iter() + .position(|(sent, _)| Some(sent.as_str()) == text) + else { + return Vec::new(); + }; + let (_, request_id) = steers.remove(position).expect("position is in range"); + vec![AgentEvent::SteerAccepted { request_id }] + } +} + +impl Models { + fn window(&self) -> Option { + self.windows.get(self.current.as_deref()?).copied() + } +} + +impl Dialect for Grok { + fn name(&self) -> &str { + "Grok" + } + + fn launch(&self, opts: &SessionOptions) -> Result { + let program = crate::resolve_binary(opts.binary_path.as_deref(), "grok")?; + // `acceptEdits` at launch still asks before every edit, so + // AutoAcceptEdits runs in `default` and approves edits itself + // (`auto_approves`). `default` already runs reads unprompted. + let permission_mode = match opts.approval_mode { + ApprovalMode::Supervised | ApprovalMode::ReadOnly | ApprovalMode::AutoAcceptEdits => { + "default" + } + ApprovalMode::FullAccess => "bypassPermissions", + }; + let mut args = vec![ + "--permission-mode".to_string(), + permission_mode.to_string(), + "agent".to_string(), + ]; + if let Some(model) = &opts.model { + args.extend(["--model".to_string(), model.clone()]); + } + if let Some(effort) = + acp_session::config_selection(&opts.option_selections, "reasoning_effort") + { + args.extend(["--reasoning-effort".to_string(), effort.to_string()]); + } + args.extend(opts.extra_args.iter().cloned()); + args.push("stdio".to_string()); + Ok(Launch { + program, + args, + env: Vec::new(), + client_meta: None, + }) + } + + async fn establish(&self, setup: &Setup<'_>) -> Result { + let (connection, opts) = (setup.connection, setup.opts); + if let Some(state) = model_state(setup.init) { + let mut models = self.models(); + models.windows = available_models(state) + .filter_map(|model| { + let id = model.get("modelId")?.as_str()?; + let window = model.pointer("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/_meta/totalContextTokens")?.as_u64()?; + Some((id.to_string(), window)) + }) + .collect(); + models.current = opts.model.clone().or_else(|| { + state + .get("currentModelId") + .and_then(Value::as_str) + .map(str::to_string) + }); + } + let offers_api_key = setup.init.auth_methods.iter().any(|method| { + let id = match method { + acp::AuthMethod::Agent(method) => &method.id, + acp::AuthMethod::EnvVar(method) => &method.id, + acp::AuthMethod::Terminal(method) => &method.id, + _ => return false, + }; + id.0.as_ref() == API_KEY_AUTH_METHOD + }); + let authenticated = if offers_api_key { + connection + .send_request(acp::AuthenticateRequest::new(API_KEY_AUTH_METHOD)) + .block_task() + .await + .map(|_| ()) + } else { + Ok(()) + }; + let auth_failure = |err: &acp::Error| { + let detail = match &authenticated { + Err(auth) => describe(auth), + Ok(()) => describe(err), + }; + AgentError::Provider(format!( + "Grok requires authentication: {detail}. Set XAI_API_KEY in Settings → Providers → Grok, or sign in with `grok login`." + )) + }; + + let mcp_servers = setup.mcp_servers().await; + let resumed = opts + .resume + .as_ref() + .and_then(|cursor| cursor.str_field(&["session_id"])) + .map(str::to_string); + let (session_id, modes, config_options, existing) = match resumed { + Some(source) => { + let session_id = if opts.fork { + fork(connection, &source, opts).await? + } else { + source + }; + let resumed = connection + .send_request( + acp::ResumeSessionRequest::new(session_id.clone(), opts.cwd.clone()) + .mcp_servers(mcp_servers), + ) + .block_task() + .await + .map_err(|err| { + if is_auth_required(&err) { + auth_failure(&err) + } else { + AgentError::Provider(format!( + "Grok could not resume session {session_id}: {}", + describe(&err) + )) + } + })?; + ( + acp::SessionId::new(session_id), + resumed.modes, + resumed.config_options, + true, + ) + } + None => { + let created = connection + .send_request( + acp::NewSessionRequest::new(opts.cwd.clone()).mcp_servers(mcp_servers), + ) + .block_task() + .await + .map_err(|err| { + if is_auth_required(&err) { + auth_failure(&err) + } else { + AgentError::Protocol(format!( + "Grok could not start a session: {}", + describe(&err) + )) + } + })?; + ( + created.session_id, + created.modes, + created.config_options, + false, + ) + } + }; + + let mut mode = match opts.interaction_mode { + InteractionMode::Plan => PLAN_MODE, + InteractionMode::Build => DEFAULT_MODE, + }; + // Grok keeps a session's plan mode across processes without reporting + // it, so a continued session is always told its mode. + if existing || mode == PLAN_MODE { + let applied = connection + .send_request(acp::SetSessionModeRequest::new( + session_id.clone(), + acp::SessionModeId::new(mode), + )) + .block_task() + .await; + if let Err(err) = applied { + setup + .warn(format!( + "Grok did not switch to its {mode} mode: {}", + describe(&err) + )) + .await; + mode = DEFAULT_MODE; + } + } + let modes = modes.unwrap_or_else(|| { + acp::SessionModeState::new( + mode, + vec![ + acp::SessionMode::new(DEFAULT_MODE, "Default"), + acp::SessionMode::new(PLAN_MODE, "Plan"), + ], + ) + }); + setup.adopt(Some(&modes), config_options.as_deref()); + Ok(Established { + resume: ResumeCursor(json!({ "session_id": session_id.0.to_string() })), + session_id, + }) + } + + fn session_update( + &self, + state: &mut State, + notification: acp::SessionNotification, + ) -> Vec { + // `session/load` replays history flagged as such; tcode's own log is + // the conversation of record. + let replayed = notification + .meta + .as_ref() + .and_then(|meta| meta.get("isReplay")) + .and_then(Value::as_bool) + .unwrap_or(false); + if replayed { + return Vec::new(); + } + let mut update = notification.update; + let shell = match &mut update { + acp::SessionUpdate::ToolCallUpdate(update) => { + let output = update.fields.raw_output.as_ref(); + if output.and_then(|raw| raw.get("type")) == Some(&json!("Bash")) + && self + .activity() + .backgrounded + .contains(update.tool_call_id.0.as_ref()) + { + update.fields.status = None; + } + prepare_tool_update(update) + } + _ => None, + }; + let mut events = state.apply_update(update); + if let Some(shell) = shell { + events.iter_mut().for_each(|event| shell.render(event)); + } + events + } + + fn turn_end( + &self, + _state: &mut State, + result: Result, + ) -> TurnEnd { + let (status, message) = match (prompt_status(&result), &result) { + ((TurnStatus::Failed, _), Err(err)) => (TurnStatus::Failed, Some(failure_message(err))), + (outcome, _) => outcome, + }; + TurnEnd { + status, + message, + usage: result + .as_ref() + .ok() + .and_then(|response| response.meta.as_ref()) + .map(|meta| self.turn_usage(meta)), + } + } + + async fn steer( + &self, + session: &Session, + request_id: String, + text: String, + _attachments: Vec, + ) { + let Some(session_id) = session.id() else { + return; + }; + self.steers().push_back((text.clone(), request_id.clone())); + let sent = session + .connection + .send_request(UntypedMessage { + method: INTERJECT.into(), + params: json!({ "sessionId": session_id.0.to_string(), "text": text }), + }) + .block_task() + .await; + if let Err(err) = sent { + self.steers().retain(|(_, pending)| pending != &request_id); + session + .emit(AgentEvent::Warning { + message: format!("Grok did not accept the steer: {}", describe(&err)), + }) + .await; + } + } + + async fn set_approval_mode(&self, _session: &Session, _mode: ApprovalMode) { + // Applied as `--permission-mode` when the runtime restarts the session + // for the next turn. + } + + /// Offered `fs/*` and `terminal/*`, Grok runs every read, write and + /// command through them instead of its own tools. + fn client_services(&self) -> bool { + false + } + + fn owned_config_options(&self) -> &'static [&'static str] { + &["model"] + } + + fn auto_approves(&self, tool_call: &acp::ToolCallUpdate) -> bool { + self.approval_mode == ApprovalMode::AutoAcceptEdits + && matches!( + tool_call.fields.kind, + Some(acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move) + ) + } + + fn handles_request(&self, method: &str) -> bool { + matches!(method, ASK_USER_QUESTION | EXIT_PLAN_MODE) + } + + async fn request( + &self, + session: Session, + method: String, + params: Value, + ) -> Result { + if method == EXIT_PLAN_MODE { + let item_id = params.get("toolCallId").and_then(Value::as_str); + let markdown = params + .get("planContent") + .and_then(Value::as_str) + .filter(|plan| !plan.trim().is_empty()); + if let (Some(item_id), Some(markdown)) = (item_id, markdown) { + session + .emit(AgentEvent::ProposedPlan { + item_id: item_id.to_string(), + markdown: markdown.to_string(), + }) + .await; + } + // The decision is the user's, through tcode's plan flow, which + // implements in a later Build-mode turn. Approving here would let + // Grok implement before that, and holding the request would keep + // the turn open under it; every outcome but approval and + // abandonment keeps Grok planning. + return Ok(json!({ "outcome": "request_changes" })); + } + let questions = questions(¶ms)?; + Ok(match session.ask_user(questions).await { + Some(answers) => json!({ "outcome": "accepted", "answers": answer_lists(answers) }), + None => json!({ "outcome": "cancelled" }), + }) + } + + fn notification(&self, state: &mut State, method: &str, params: Value) -> Vec { + let mut events = match method { + INTERJECTION => self.steer_accepted(¶ms), + QUEUE_CHANGED => self.queue_changed(state, ¶ms), + TASK_BACKGROUNDED => { + if let Some(id) = params + .pointer("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/update/tool_call_id") + .and_then(Value::as_str) + { + self.activity().backgrounded.insert(id.to_string()); + } + Vec::new() + } + SESSION_NOTIFICATION => params + .get("update") + .map(|update| self.session_notification(state, update)) + .unwrap_or_default(), + _ => Vec::new(), + }; + let mut activity = self.activity(); + if activity.drained && !activity.sent_turn_open(state) { + activity.drained = false; + events.push(AgentEvent::BackgroundTasksChanged { count: 0 }); + } + events + } +} + +/// Grok fails a turn with `-32603 Internal error`, its own words in `data`. +fn failure_message(err: &acp::Error) -> String { + err.data + .as_ref() + .and_then(|data| data.get("message")) + .and_then(Value::as_str) + .map_or_else(|| describe(err), str::to_string) +} + +fn retry_message(update: &Value) -> String { + let reason = update + .get("reason") + .and_then(Value::as_str) + .unwrap_or("Grok's model request failed"); + match ( + update.get("attempt").and_then(Value::as_u64), + update.get("max_retries").and_then(Value::as_u64), + ) { + (Some(attempt), Some(max)) => format!("{reason} (retry {attempt} of {max})"), + _ => reason.to_string(), + } +} + +/// Grok's shell tool resends the command's whole output with every update. +struct ShellResult { + item_id: String, + output: String, + exit_code: Option, +} + +impl ShellResult { + fn render(&self, event: &mut AgentEvent) { + if let AgentEvent::ItemStarted(item) + | AgentEvent::ItemUpdated(item) + | AgentEvent::ItemCompleted(item) = event + && item.id == self.item_id + && let ItemContent::CommandExecution { + output, exit_code, .. + } = &mut item.content + { + output.clone_from(&self.output); + *exit_code = self.exit_code; + } + } +} + +/// Bring a `tool_call_update` into the shape the standard mapping renders +/// faithfully. Grok's `rawOutput` is its typed tool result, not display text, +/// so it must never become an item's output. Returns the shell result the +/// mapped item shows instead. +fn prepare_tool_update(update: &mut acp::ToolCallUpdate) -> Option { + let fields = &mut update.fields; + let raw_type = fields + .raw_output + .as_ref() + .and_then(|raw| raw.get("type")) + .and_then(Value::as_str); + match raw_type { + Some("Bash") => { + let finished = matches!( + fields.status, + Some(acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed) + ); + // Running updates carry a placeholder exit code. + let exit_code = fields + .raw_output + .as_ref() + .and_then(|raw| raw.get("exit_code")) + .and_then(Value::as_i64) + .filter(|_| finished) + .map(|code| code as i32); + Some(ShellResult { + item_id: update.tool_call_id.0.to_string(), + output: text_of(fields.content.as_deref().unwrap_or_default()), + exit_code, + }) + } + Some("MCP") => { + let text = fields + .raw_output + .as_ref() + .and_then(|raw| raw.pointer("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/output/OkayOutput")) + .map(|output| match output { + Value::String(text) => text.clone(), + other => other.to_string(), + }); + if fields.content.is_none() + && let Some(text) = text + { + fields.content = Some(vec![text.into()]); + } + None + } + // Before a command runs, its content is the model's description of it. + None if fields.kind == Some(acp::ToolKind::Execute) => { + fields.content = None; + None + } + _ => None, + } +} + +fn text_of(content: &[acp::ToolCallContent]) -> String { + content + .iter() + .filter_map(|content| match content { + acp::ToolCallContent::Content(block) => match &block.content { + acp::ContentBlock::Text(text) => Some(text.text.as_str()), + _ => None, + }, + _ => None, + }) + .collect() +} + +/// Fork `source` into a new Grok session in the same directory. +async fn fork( + connection: &acp_session::Connection, + source: &str, + opts: &SessionOptions, +) -> Result { + let cwd = opts.cwd.to_string_lossy(); + let forked = connection + .send_request(UntypedMessage { + method: FORK.into(), + params: json!({ "sourceSessionId": source, "sourceCwd": cwd, "newCwd": cwd }), + }) + .block_task() + .await + .map_err(|err| { + AgentError::Provider(format!( + "Grok could not fork session {source}: {}", + describe(&err) + )) + })?; + forked + .get("newSessionId") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| AgentError::Protocol(format!("Grok's fork returned no session: {forked}"))) +} + +/// The questions of an `_x.ai/ask_user_question` request. Grok keys answers +/// by question text. +fn questions(params: &Value) -> Result, acp::Error> { + let questions = params + .get("questions") + .and_then(Value::as_array) + .ok_or_else(acp::Error::invalid_params)?; + Ok(questions + .iter() + .filter_map(|question| { + let text = question.get("question")?.as_str()?.to_string(); + Some(UserInputQuestion { + id: text.clone(), + header: question + .get("header") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + question: text, + options: question + .get("options") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|option| { + Some(UserInputOption { + label: option.get("label")?.as_str()?.to_string(), + description: option + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }) + }) + .collect(), + multi_select: question + .get("multiSelect") + .and_then(Value::as_bool) + .unwrap_or(false), + prefill: None, + }) + }) + .collect()) +} + +/// Grok takes every answer as a list of selected labels. +fn answer_lists(answers: Map) -> Map { + answers + .into_iter() + .map(|(question, answer)| { + let answer = match answer { + Value::Array(_) => answer, + other => Value::Array(vec![other]), + }; + (question, answer) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::io::{BufRead as _, Write as _}; + use std::time::Duration; + + use super::*; + use crate::{ + ApprovalDecision, ApprovalKind, ContextFreshness, FileChangeKind, ItemStatus, + McpRegistration, OptionSelection, SessionCommand, ThreadItem, + }; + + // Grok 1.0.46 sessions recorded on the wire in both directions + // (provenance: `tests/fixtures/grok/README.md`). + /// A resumed session under AutoAcceptEdits. + const RESUMED_TURN: &str = include_str!("../tests/fixtures/grok/resumed_turn.jsonl"); + /// A background task finishing during a sent turn, the turn Grok starts + /// to report it, then another sent turn. + const AGENT_STARTED_TURN: &str = + include_str!("../tests/fixtures/grok/agent_started_turn.jsonl"); + const REPLAY_AGENT: &str = "TCODE_GROK_REPLAY_AGENT"; + + /// Stand in for `grok agent stdio`: answer the client with the recorded + /// agent messages, and require the client to send what Tcode sent, with + /// the same answers to Grok's own requests. + fn replay_agent(fixture: &str) { + let mut client = std::io::stdin().lock().lines(); + let mut agent = std::io::stdout().lock(); + let mut live_ids: HashMap = HashMap::new(); + for record in fixture.lines() { + let record: Value = serde_json::from_str(record).unwrap(); + let recorded = &record["message"]; + if record["from"] == "agent" { + let mut message = recorded.clone(); + if message.get("method").is_none() { + message["id"] = live_ids[&message["id"].to_string()].clone(); + } + writeln!(agent, "{message}").unwrap(); + agent.flush().unwrap(); + continue; + } + let live = loop { + let line = client.next().expect("the client hung up early").unwrap(); + // The client's protocol errors about the test harness's own + // non-JSON output carry no id. + if let Ok(live) = serde_json::from_str::(&line) + && !live.get("id").is_some_and(Value::is_null) + { + break live; + } + }; + assert_eq!(live.get("method"), recorded.get("method"), "{live}"); + if recorded["method"] == "initialize" { + let capabilities = + |message: &Value| message["params"]["clientCapabilities"].clone(); + assert_eq!(capabilities(&live), capabilities(recorded), "{live}"); + } + match recorded.get("id") { + Some(id) if recorded.get("method").is_some() => { + live_ids.insert(id.to_string(), live["id"].clone()); + } + _ => assert_eq!(live["result"], recorded["result"], "{live}"), + } + } + for _ in client {} + } + + /// A stand-in `grok` that runs the test `test` as [`replay_agent`]; + /// whatever arguments Tcode launches Grok with are left unread. + fn stand_in_binary(dir: &std::path::Path, test: &str) -> PathBuf { + let test = format!("grok::tests::{test}"); + let exe = std::env::current_exe().unwrap(); + let exe = exe.display(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let path = dir.join("grok"); + let script = format!("#!/bin/sh\nexec '{exe}' --exact {test} --nocapture\n"); + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + #[cfg(windows)] + { + let path = dir.join("grok.cmd"); + std::fs::write(&path, format!("@\"{exe}\" --exact {test} --nocapture\r\n")).unwrap(); + path + } + } + + fn command_outputs<'a>(events: &'a [AgentEvent], id: &str) -> Vec<(&'a str, Option)> { + events + .iter() + .filter_map(|event| match event { + AgentEvent::ItemStarted(item) + | AgentEvent::ItemUpdated(item) + | AgentEvent::ItemCompleted(item) + if item.id == id => + { + match &item.content { + ItemContent::CommandExecution { + output, exit_code, .. + } => Some((output.as_str(), *exit_code)), + _ => None, + } + } + _ => None, + }) + .collect() + } + + fn completed<'a>(events: &'a [AgentEvent], id: &str) -> &'a ItemContent { + events + .iter() + .find_map(|event| match event { + AgentEvent::ItemCompleted(ThreadItem { + id: item, content, .. + }) if item == id => Some(content), + _ => None, + }) + .unwrap_or_else(|| panic!("{id} never completed: {events:#?}")) + } + + fn completed_last<'a>(events: &'a [AgentEvent], id: &str) -> &'a ItemContent { + events + .iter() + .rev() + .find_map(|event| match event { + AgentEvent::ItemCompleted(ThreadItem { + id: item, content, .. + }) if item == id => Some(content), + _ => None, + }) + .unwrap_or_else(|| panic!("{id} never completed: {events:#?}")) + } + + fn streamed(events: &[AgentEvent], id: &str) -> String { + events + .iter() + .filter_map(|event| match event { + AgentEvent::Delta { item_id, text, .. } if item_id == id => Some(text.as_str()), + _ => None, + }) + .collect() + } + + /// Options for a session against a stand-in Grok running `test`, in a + /// fresh directory. + fn stand_in_options(test: &str) -> SessionOptions { + let dir = + std::env::temp_dir().join(format!("tcode-grok-replay-{}-{test}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + SessionOptions { + binary_path: Some(stand_in_binary(&dir, test)), + cwd: dir, + model: None, + resume: None, + fork: false, + approval_mode: ApprovalMode::FullAccess, + option_selections: Vec::new(), + interaction_mode: InteractionMode::Build, + mcp_servers: Vec::new(), + launch_env: LaunchEnv { + env: vec![(REPLAY_AGENT.into(), "1".into())], + home: None, + }, + extra_args: Vec::new(), + acp: None, + } + } + + /// What the person driving a replayed session does after a turn completes. + enum Next { + Wait, + Send(&'static str), + Close, + } + + /// Run a session as the app does: send `first`, approve once and answer + /// questions with their first option, and after the `n`th completed turn + /// do `next(n)`. Returns every event through the close. + fn replay(opts: SessionOptions, first: &str, next: impl Fn(usize) -> Next) -> Vec { + let dir = opts.cwd.clone(); + let events = smol::block_on(smol::future::or( + async { + let handle = start(opts).await.unwrap(); + let send = |delivery_id, text: &str| SessionCommand::SendTurn { + delivery_id, + text: text.into(), + options: None, + attachments: Vec::new(), + }; + handle.commands.send(send(1, first)).await.unwrap(); + let (mut events, mut completed) = (Vec::new(), 0); + while let Ok(event) = handle.events.recv().await { + let command = match &event { + AgentEvent::ApprovalRequested(request) => { + Some(SessionCommand::RespondApproval { + request_id: request.id.clone(), + decision: ApprovalDecision::Approve, + }) + } + AgentEvent::UserInputRequested { + request_id, + questions, + .. + } => Some(SessionCommand::RespondUserInput { + request_id: request_id.clone(), + answers: questions + .iter() + .map(|question| { + (question.id.clone(), json!(question.options[0].label)) + }) + .collect(), + }), + AgentEvent::TurnCompleted { .. } => { + completed += 1; + match next(completed) { + Next::Wait => None, + Next::Send(text) => Some(send(completed as u64 + 1, text)), + Next::Close => Some(SessionCommand::Shutdown), + } + } + _ => None, + }; + let closed = matches!(event, AgentEvent::SessionClosed { .. }); + events.push(event); + if let Some(command) = command { + handle.commands.send(command).await.unwrap(); + } + if closed { + break; + } + } + events + }, + async { + smol::Timer::after(Duration::from_secs(60)).await; + panic!("the replayed session did not finish"); + }, + )); + let _ = std::fs::remove_dir_all(&dir); + // Every client message and answer matched the recording, or the + // stand-in would have hung up before the close. + assert!( + matches!( + events.last(), + Some(AgentEvent::SessionClosed { reason: None }) + ), + "{events:#?}" + ); + events + } + + #[test] + fn recorded_resumed_turn_maps_to_the_canonical_stream() { + if std::env::var_os(REPLAY_AGENT).is_some() { + return replay_agent(RESUMED_TURN); + } + let opts = SessionOptions { + resume: Some(ResumeCursor( + json!({ "session_id": "01a10095-e65c-7983-a817-6884b010f1ae" }), + )), + approval_mode: ApprovalMode::AutoAcceptEdits, + option_selections: vec![OptionSelection { + id: "acp:cfg:reasoning_effort".into(), + value: json!("low"), + }], + mcp_servers: vec![McpRegistration { + name: "tcode_probe".into(), + url: "http://127.0.0.1:18433/mcp".into(), + bearer_token: "tcode-secret".into(), + }], + ..stand_in_options("recorded_resumed_turn_maps_to_the_canonical_stream") + }; + let events = replay(opts, "Count, edit, ask and echo", |_| Next::Close); + + let started = events + .iter() + .position(|event| matches!(event, AgentEvent::TurnStarted { .. })) + .unwrap(); + assert!( + !events[..started].iter().any(|event| matches!( + event, + AgentEvent::Delta { .. } + | AgentEvent::ItemStarted(_) + | AgentEvent::ItemUpdated(_) + | AgentEvent::ItemCompleted(_) + )), + "a resumed session replays no history: {:#?}", + &events[..started] + ); + + assert_eq!(streamed(&events, "thought-1"), "Counting first."); + assert!(matches!(completed(&events, "thought-1"), + ItemContent::Reasoning { text } if text == "Counting first.")); + + // Each shell update carries the whole output so far; the item shows + // it, never the command's description or Grok's typed result. + let shell = command_outputs(&events, "call_33_1"); + assert_eq!( + shell.last(), + Some(&("line1\nline2\nline3\n", Some(0))), + "{shell:?}" + ); + assert!( + shell + .windows(2) + .all(|pair| pair[1].0.starts_with(pair[0].0) && pair[0].1.is_none()), + "{shell:?}" + ); + assert!(shell.iter().any(|(output, _)| *output == "line1\n")); + assert_eq!( + command_outputs(&events, "call_34_0").last(), + Some(&("", Some(0))) + ); + + assert!(matches!(completed(&events, "call_35_0"), + ItemContent::FileChange { changes, status: ItemStatus::Completed } + if changes.len() == 1 && changes[0].kind == FileChangeKind::Modify + && changes[0].path.ends_with("/in.txt"))); + let approvals: Vec<_> = events + .iter() + .filter_map(|event| match event { + AgentEvent::ApprovalRequested(request) => Some(&request.kind), + _ => None, + }) + .collect(); + assert!( + matches!(approvals.as_slice(), [ + ApprovalKind::ExecCommand { command, .. }, + ApprovalKind::ToolUse { name, .. }, + ] if command.starts_with("for i in") && name == "tcode_probe__echo_upper"), + "AutoAcceptEdits approves the edit itself: {approvals:#?}" + ); + assert!(events.iter().any(|event| matches!(event, + AgentEvent::UserInputRequested { questions, .. } + if questions[0].id == "Which greeting should I use?"))); + assert!(matches!(completed(&events, "call_37_0"), + ItemContent::ToolCall { name, output: Some(output), status: ItemStatus::Completed, .. } + if name == "tcode_probe__echo_upper" && output == "HELLO MCP")); + + assert_eq!( + streamed(&events, "msg-2"), + "Counted, edited, asked and echoed." + ); + assert!(matches!(completed(&events, "msg-2"), + ItemContent::AssistantMessage { text } if text == "Counted, edited, asked and echoed.")); + + assert!(events.iter().any(|event| matches!(event, + AgentEvent::TokenUsage(usage) + if usage.context_window == Some(500_000) && usage.used_tokens == Some(1234)))); + let completions: Vec<_> = events + .iter() + .filter_map(|event| match event { + AgentEvent::TurnCompleted { status, usage, .. } => Some((*status, *usage)), + _ => None, + }) + .collect(); + assert_eq!(completions.len(), 1, "{completions:?}"); + let (status, usage) = completions[0]; + assert_eq!(status, TurnStatus::Completed); + let usage = usage.expect("the turn reports its usage"); + assert_eq!(usage.freshness, ContextFreshness::Current); + assert_eq!(usage.turn_processed_tokens, Some(7404)); + assert_eq!(usage.used_tokens, Some(1234)); + assert_eq!(usage.context_window, Some(500_000)); + } + + /// Grok reports a background task that finished during a sent turn with + /// a turn of its own, which no `session/prompt` answers. It starts while + /// the sent turn's result is still on its way. + #[test] + fn a_turn_grok_starts_itself_runs_between_the_sent_turns() { + if std::env::var_os(REPLAY_AGENT).is_some() { + return replay_agent(AGENT_STARTED_TURN); + } + let opts = stand_in_options("a_turn_grok_starts_itself_runs_between_the_sent_turns"); + let events = replay( + opts, + "Background then foreground", + |completed| match completed { + 1 => Next::Wait, + 2 => Next::Send("And now?"), + _ => Next::Close, + }, + ); + + let agent = "task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b"; + let at = |wanted: &dyn Fn(&AgentEvent) -> bool| { + events + .iter() + .position(wanted) + .unwrap_or_else(|| panic!("{events:#?}")) + }; + let lifecycle: Vec<(&str, &str, Option)> = events + .iter() + .filter_map(|event| match event { + AgentEvent::TurnStarted { turn_id } => Some(("started", turn_id.as_str(), None)), + AgentEvent::TurnCompleted { turn_id, usage, .. } => Some(( + "completed", + turn_id.as_str(), + usage.and_then(|usage| usage.turn_processed_tokens), + )), + _ => None, + }) + .collect(); + assert_eq!( + lifecycle, + [ + ("started", "turn-1", None), + ("completed", "turn-1", Some(3702)), + ("started", agent, None), + ("completed", agent, Some(1234)), + ("started", "turn-2", None), + ("completed", "turn-2", Some(1234)), + ] + ); + + let reply = at(&|event| { + matches!(event, AgentEvent::ItemCompleted(ThreadItem { + content: ItemContent::AssistantMessage { text }, .. + }) if text == "The background task printed bgdone.") + }); + let agent_started = + at(&|event| matches!(event, AgentEvent::TurnStarted { turn_id } if turn_id == agent)); + let agent_completed = at( + &|event| matches!(event, AgentEvent::TurnCompleted { turn_id, .. } if turn_id == agent), + ); + assert!(agent_started < reply && reply < agent_completed); + + // The runtime must not take the process for idle before Grok's turn. + let sent_completed = at( + &|event| matches!(event, AgentEvent::TurnCompleted { turn_id, .. } if turn_id == "turn-1"), + ); + let drained = at(&|event| matches!(event, AgentEvent::BackgroundTasksChanged { count: 0 })); + assert!(sent_completed < drained, "{events:#?}"); + + // The backgrounded command's later output leaves its card completed. + let background = events + .iter() + .filter(|event| { + matches!(event, AgentEvent::ItemUpdated(item) | AgentEvent::ItemCompleted(item) + if item.id == "call_68_0") + }) + .skip_while(|event| !matches!(event, AgentEvent::ItemCompleted(_))) + .collect::>(); + assert!( + background + .iter() + .all(|event| matches!(event, AgentEvent::ItemCompleted(_))), + "{background:#?}" + ); + assert!(matches!(completed_last(&events, "call_68_0"), + ItemContent::CommandExecution { output, status: ItemStatus::Completed, .. } + if output == "bgdone\n")); + } +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index ccccbca13..9d4d68170 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -9,6 +9,8 @@ #[cfg(feature = "process")] pub mod acp; #[cfg(feature = "process")] +mod acp_session; +#[cfg(feature = "process")] mod actor; #[cfg(feature = "process")] pub mod claude; @@ -21,6 +23,10 @@ pub mod claude { #[cfg(feature = "process")] pub mod codex; #[cfg(feature = "process")] +pub mod cursor; +#[cfg(feature = "process")] +pub mod grok; +#[cfg(feature = "process")] pub mod opencode; #[cfg(feature = "process")] pub mod pi; @@ -42,10 +48,12 @@ pub enum ProviderKind { ClaudeCode, Pi, OpenCode, + Cursor, + Grok, /// Any agent speaking the Agent Client Protocol. Which one is carried - /// separately ([`SessionOptions::acp`]) so this stays `Copy`: Codex and - /// Claude Code keep their richer native clients, and ACP covers the rest - /// of the ecosystem. + /// separately ([`SessionOptions::acp`]) so this stays `Copy`: the native + /// providers keep their own clients, and ACP covers the rest of the + /// ecosystem. Acp, } @@ -92,6 +100,17 @@ pub enum OptionDescriptors { } impl ProviderKind { + /// The natively maintained providers, in the order provider lists present + /// them. + pub const NATIVE: [ProviderKind; 6] = [ + ProviderKind::ClaudeCode, + ProviderKind::Codex, + ProviderKind::Pi, + ProviderKind::OpenCode, + ProviderKind::Cursor, + ProviderKind::Grok, + ]; + /// Provider behavior consumed by runtime and UI policy. /// /// This is deliberately one exhaustive table so adding a provider forces a @@ -175,6 +194,36 @@ impl ProviderKind { home_path: false, trust_project_extensions: false, }, + ProviderKind::Cursor => Caps { + supports_steering: false, + supports_fork: false, + native_rewind: false, + per_turn_effort: false, + options_apply_live: true, + live_approval_mode_switch: false, + live_option_push: LiveOptionPush::All, + mcp_servers: true, + launch_args: true, + downgrade_approval_without_native_approvals: false, + option_descriptors: OptionDescriptors::Wire, + home_path: true, + trust_project_extensions: false, + }, + ProviderKind::Grok => Caps { + supports_steering: true, + supports_fork: true, + native_rewind: false, + per_turn_effort: false, + options_apply_live: true, + live_approval_mode_switch: false, + live_option_push: LiveOptionPush::All, + mcp_servers: true, + launch_args: true, + downgrade_approval_without_native_approvals: false, + option_descriptors: OptionDescriptors::Wire, + home_path: true, + trust_project_extensions: false, + }, } } @@ -184,14 +233,23 @@ impl ProviderKind { ProviderKind::ClaudeCode => "Claude Code", ProviderKind::Pi => "pi", ProviderKind::OpenCode => "OpenCode", + ProviderKind::Cursor => "Cursor", + ProviderKind::Grok => "Grok", ProviderKind::Acp => "ACP agent", } } } -/// Registry agents we never surface because they duplicate richer native -/// integrations. -pub const HIDDEN_ACP_AGENT_IDS: [&str; 4] = ["claude-acp", "codex-acp", "pi-acp", "opencode"]; +/// Registry agents never offered for new sessions because they duplicate a +/// native integration. +pub const HIDDEN_ACP_AGENT_IDS: [&str; 6] = [ + "claude-acp", + "codex-acp", + "pi-acp", + "opencode", + "cursor", + "grok-build", +]; /// One ACP agent a session can run: its registry identity plus how to launch it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -344,7 +402,8 @@ pub struct LaunchEnv { /// entries win, and these override anything inherited from the parent. pub env: Vec<(String, String)>, /// Home-directory override. Provider-specific: Claude gets `HOME`, Codex - /// gets `CODEX_HOME`, and pi gets `PI_CODING_AGENT_DIR`. OpenCode has no + /// gets `CODEX_HOME`, pi gets `PI_CODING_AGENT_DIR`, Cursor gets + /// `CURSOR_CONFIG_DIR` and Grok gets `GROK_HOME`. OpenCode has no /// supported single-directory override, so its value is ignored. pub home: Option, } @@ -360,6 +419,8 @@ impl LaunchEnv { ProviderKind::ClaudeCode => Some("HOME"), ProviderKind::Codex => Some("CODEX_HOME"), ProviderKind::Pi => Some("PI_CODING_AGENT_DIR"), + ProviderKind::Cursor => Some("CURSOR_CONFIG_DIR"), + ProviderKind::Grok => Some("GROK_HOME"), ProviderKind::OpenCode => None, // ACP agents carry their own env in the launch recipe; there is // no protocol-level home concept to override. @@ -640,8 +701,8 @@ impl UserInputDelivery { /// free-text) prompt; answers ride back through [`SessionCommand::RespondUserInput`]. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct UserInputQuestion { - /// The answer key. Claude: the complete question text (the SDK indexes - /// answers by question text). Codex: the native question id. + /// The answer key. Claude and Grok: the complete question text (both index + /// answers by question text). Codex and Cursor: the native question id. pub id: String, pub header: String, pub question: String, @@ -738,6 +799,8 @@ pub async fn list_models( ProviderKind::ClaudeCode => claude::list_models(binary_path, launch_env, refresh).await, ProviderKind::Pi => pi::list_models(binary_path, launch_env).await, ProviderKind::OpenCode => opencode::list_models(binary_path, launch_env).await, + ProviderKind::Cursor => cursor::list_models(binary_path, launch_env).await, + ProviderKind::Grok => grok::list_models(binary_path, launch_env).await, // ACP agents advertise their models over the wire at session start // (`AgentEvent::ProviderOptions`), so there is no catalog to pre-fetch. ProviderKind::Acp => Ok(Vec::new()), @@ -753,6 +816,11 @@ pub async fn list_models( /// (mid-session switch may require a resume-restart). /// - pi: a bundled fail-closed tool-call extension in front of RPC extension UI. /// - OpenCode: `OPENCODE_PERMISSION` rules plus permission reply endpoints. +/// - Grok: `--permission-mode` default / bypassPermissions at launch; under +/// AutoAcceptEdits tcode approves Grok's edit permission requests once. +/// - Cursor: `--force` at launch for FullAccess; otherwise its own permission +/// requests, of which tcode approves reads and searches once under +/// ReadOnly, and file changes once under AutoAcceptEdits. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ApprovalMode { @@ -856,9 +924,9 @@ pub enum SessionCommand { /// native Plan mode. SetInteractionMode(InteractionMode), /// Set one of the agent's self-described options (see - /// [`AgentEvent::ProviderOptions`]). ACP routes it to `session/set_mode`, - /// `session/set_model` or `session/set_config_option` by the descriptor's - /// origin; the native providers ignore ids they do not know. + /// [`AgentEvent::ProviderOptions`]). An agent speaking ACP gets + /// `session/set_mode` or `session/set_config_option` by the descriptor's + /// origin; the other providers ignore ids they do not know. SetOption { id: String, value: serde_json::Value, @@ -924,6 +992,8 @@ pub async fn start_session( ProviderKind::ClaudeCode => claude::start(opts).await, ProviderKind::Pi => pi::start(opts).await, ProviderKind::OpenCode => opencode::start(opts).await, + ProviderKind::Cursor => cursor::start(opts).await, + ProviderKind::Grok => grok::start(opts).await, ProviderKind::Acp => acp::start(opts).await, } } @@ -1421,10 +1491,10 @@ pub struct ApprovalRequest { pub id: String, pub turn_id: Option, pub kind: ApprovalKind, - /// Agent-supplied choices. ACP agents send their own option list - /// (`session/request_permission`), so the UI renders exactly those buttons. - /// Empty for the native providers, whose four fixed decisions - /// ([`ApprovalDecision`]) apply instead. + /// Agent-supplied choices. An agent speaking ACP (a registry agent, Cursor + /// or Grok) sends its own option list (`session/request_permission`), so the + /// UI renders exactly those buttons. Empty for the other providers, whose + /// four fixed decisions ([`ApprovalDecision`]) apply instead. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub options: Vec, } @@ -1484,7 +1554,7 @@ pub enum ApprovalDecision { /// `"User cancelled tool execution."` (no interrupt); Codex maps it to the /// protocol `{decision:"cancel"}` (deny + immediate turn interruption). Cancel, - /// Pick one of the agent's own [`ApprovalOption`]s (ACP only). + /// Pick one of the agent's own [`ApprovalOption`]s (agents speaking ACP). Option(String), } @@ -1820,6 +1890,17 @@ mod turn_diff_tests { mod thread_item_serde_tests { use super::*; + #[test] + fn cursor_and_grok_use_their_wire_names() { + for (kind, wire) in [ + (ProviderKind::Cursor, r#""cursor""#), + (ProviderKind::Grok, r#""grok""#), + ] { + assert_eq!(serde_json::to_string(&kind).unwrap(), wire); + assert_eq!(serde_json::from_str::(wire).unwrap(), kind); + } + } + #[test] fn provider_failure_wire_tag_and_legacy_error_remain_compatible() { let event = AgentEvent::ProviderStartFailed { diff --git a/crates/agent/tests/fixtures/cursor/PROVENANCE.md b/crates/agent/tests/fixtures/cursor/PROVENANCE.md new file mode 100644 index 000000000..d1d261165 --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/PROVENANCE.md @@ -0,0 +1,82 @@ +# Cursor fixtures + +All of these concern `cursor-agent` 2026.10.01-e373342 (macOS arm64). Two +kinds live here, and only the first is a recording. + +## Recorded + +Captured on 2026-10-03 from the real binary while signed out, through Tcode's +production path (`cargo run -p agent --example probe -- --list-models cursor` +and `probe cursor …`, with a wrapper that copied the stdio), in a sandbox +(`HOME` redirected, `AGENT_CLI_CREDENTIAL_STORE=file`, `NO_OPEN_BROWSER=1`). +Nothing was edited. + +- `recorded_signed_out.jsonl` — what the binary wrote: the `initialize` result + (line 1), then `-32000 Authentication required` for + `cursor/list_available_models` (line 2) and for `session/new` (line 3). +- `status_signed_out.json` — `cursor-agent status --format json`. + +No signed-in session has been recorded. + +## Source-derived + +Hand-built from the shipped JavaScript of the same build, not recorded: +they protect Tcode's side of the shapes as read, not that Cursor sends them. +Line numbers refer to the build's `3351.index.js` (the ACP server) after +`prettier --parser babel` 3.9.9, unless named otherwise. Model names, model +parameters, paths and texts are illustrative: Cursor's catalog comes from its +backend. + +- `status_signed_in.json` — the `status` command's signed-in report + (`8657.index.js`, `status:"authenticated"`, `userInfo`). +- `cursor-agent` — the stand-in the tests launch: `--version` and `status` + answer from these fixtures, anything else relays stdio to the test. + +`source_derived.json`, by key: + +- `session_new` — the `session/new` result (1257-1262): modes (1910-1932), + models (1845-1856) and, with `parameterizedModelPicker`, `configOptions` + (1821-1844): the `mode` and `model` selects (1682-1704) and one select per + model parameter (1649-1671), booleans as `"false"`/`"true"` (1518-1540), + categorised `thought_level` or `model_config` (1504-1517, 1656-1658, 2011). +- `set_model_gpt5`, `set_reasoning_high` — `session/set_config_option` + answers with the full refreshed `configOptions` (1412-1446); a new model + brings its own parameters (1705-1731), a parameter keeps the model + (1733-1756). +- `list_available_models` — `cursor/list_available_models` (1457-1458, + 1575-1592). +- `session_load` — the `session/load` result (1354-1358), sent after the + replay (1359-1366). +- `load_replay` — the replay `session/load` sends first (276-377): the user + message, thoughts, each tool call as `tool_call` plus a completed + `tool_call_update` with id `replay--` (348-356, 3667-3684), a + question as its tool card (4443-4446, 4578-4585, `think` 4940), and a + subagent's lifecycle (361-374, 3012-3027). +- `available_commands` — `available_commands_update` (257-275), sent as soon + as `session/new` or `session/load` has answered (1264-1268, 1361-1365). +- `turn_tools` — a live turn: `tool_call` (3630-3646, 3784-3801), then + `in_progress` (3746-3749) and `completed` with `content`, `rawOutput`, + `locations` (3753-3773). Shell: title, kind, input and + `rawOutput {exitCode, stdout, stderr}` (4309-4311, 4922, 4502-4503, + 4757-4765). Edit: diff content (4345-4348, 4929, 4511-4515, 4638-4641, + 4679-4731). MCP: `{rejected: true}` (4403-4406, 4932, 4524-4530, + 4836-4846). Grep: `{totalMatches, truncated}` (4313-4340, 4925, + 4786-4805). Text and thoughts (3624-3629, 3686-3689, 3777-3781). +- `run_error` — a thrown agent run becomes the assistant chunk + `"\n\nError: " + String(e)` (714-734), and the prompt still answers + `end_turn` (409-426). +- `ask_question` — `cursor/ask_question` params (2039-2049); the reply shapes + are read at 2053-2075. +- `create_plan_entries`, `create_plan` — the `plan` update sent first + (2265-2288) and `cursor/create_plan` params (2331-2353); replies read at + 2353-2377. +- `update_todos`, `update_todos_merge` — `cursor/update_todos`, sent once the + todo tool completes (3840-3856), statuses from 3902-3915. +- `permission_read`, `permission_edit`, `permission_shell` — + `session/request_permission` with `allow-once`, `allow-always`, + `reject-once` (2634-2663); title and kind per operation (2684-2753). +- `subagent_turn` — a task tool (4433-4438, 4936, 4552-4559, 4868-4877), its + `subagent_spawned` and `subagent_state_update` sent under the parent session + (3158-3189), and the subagent's own updates under its session id + (2956-2981, 3114-3131). +- `task` — `cursor/task` (3858-3873). diff --git a/crates/agent/tests/fixtures/cursor/cursor-agent b/crates/agent/tests/fixtures/cursor/cursor-agent new file mode 100755 index 000000000..46fa0fa6f --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/cursor-agent @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Stands in for `cursor-agent` in tests. `--version` and `status` answer from +# fixtures; anything else relays stdio to the test listening on +# 127.0.0.1:$TCODE_STAND_IN_PORT, after a first line with the arguments. +case "$1" in +--version) + echo "2026.10.01-e373342" + exit 0 + ;; +status) + cat "$TCODE_STAND_IN_STATUS" + exit 0 + ;; +esac +exec 3<>"/dev/tcp/127.0.0.1/$TCODE_STAND_IN_PORT" +printf '%s\n' "$*" >&3 +cat <&3 & +exec cat >&3 diff --git a/crates/agent/tests/fixtures/cursor/recorded_signed_out.jsonl b/crates/agent/tests/fixtures/cursor/recorded_signed_out.jsonl new file mode 100644 index 000000000..42c25e1e5 --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/recorded_signed_out.jsonl @@ -0,0 +1,3 @@ +{"jsonrpc":"2.0","id":"e486b50a-de8b-4496-ae1b-dfdb370e48c8","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":false,"image":true},"sessionCapabilities":{"list":{},"subagents":{}}},"authMethods":[{"id":"cursor_login","name":"Cursor Login","description":"Authenticate using existing Cursor login credentials. Run 'agent login' first if not logged in."}]}} +{"jsonrpc":"2.0","id":"08359b68-7d30-4638-b691-dcea54f0f4cf","error":{"code":-32000,"message":"Authentication required","data":{"message":"Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'."}}} +{"jsonrpc":"2.0","id":"d6caf204-8307-4f35-b81a-1df88359477c","error":{"code":-32000,"message":"Authentication required","data":{"message":"Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'."}}} diff --git a/crates/agent/tests/fixtures/cursor/source_derived.json b/crates/agent/tests/fixtures/cursor/source_derived.json new file mode 100644 index 000000000..68e6be11f --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/source_derived.json @@ -0,0 +1,529 @@ +{ + "session_new": { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "modes": { + "currentModeId": "agent", + "availableModes": [ + { "id": "agent", "name": "Agent", "description": "Full agent capabilities with tool access" }, + { "id": "plan", "name": "Plan", "description": "Read-only mode for planning and designing before implementation" }, + { "id": "ask", "name": "Ask", "description": "Q&A mode - no edits or command execution" } + ] + }, + "models": { + "currentModelId": "composer-1", + "availableModels": [ + { "modelId": "composer-1", "name": "Composer 1" }, + { "modelId": "gpt-5", "name": "GPT-5" } + ] + }, + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "description": "Controls how the agent executes tasks", + "category": "mode", + "type": "select", + "currentValue": "agent", + "options": [ + { "value": "agent", "name": "Agent", "description": "Full agent capabilities with tool access" }, + { "value": "plan", "name": "Plan", "description": "Read-only mode for planning and designing before implementation" }, + { "value": "ask", "name": "Ask", "description": "Q&A mode - no edits or command execution" } + ] + }, + { + "id": "model", + "name": "Model", + "description": "Controls which model is used for responses", + "category": "model", + "type": "select", + "currentValue": "composer-1", + "options": [ + { "value": "composer-1", "name": "Composer 1" }, + { "value": "gpt-5", "name": "GPT-5" } + ] + }, + { + "id": "fast", + "name": "Fast", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { "value": "false", "name": "Off" }, + { "value": "true", "name": "On" } + ] + } + ] + }, + "set_model_gpt5": { + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "description": "Controls how the agent executes tasks", + "category": "mode", + "type": "select", + "currentValue": "agent", + "options": [ + { "value": "agent", "name": "Agent", "description": "Full agent capabilities with tool access" }, + { "value": "plan", "name": "Plan", "description": "Read-only mode for planning and designing before implementation" }, + { "value": "ask", "name": "Ask", "description": "Q&A mode - no edits or command execution" } + ] + }, + { + "id": "model", + "name": "Model", + "description": "Controls which model is used for responses", + "category": "model", + "type": "select", + "currentValue": "gpt-5", + "options": [ + { "value": "composer-1", "name": "Composer 1" }, + { "value": "gpt-5", "name": "GPT-5" } + ] + }, + { + "id": "reasoning", + "name": "Reasoning", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { "value": "low", "name": "Low" }, + { "value": "medium", "name": "Medium" }, + { "value": "high", "name": "High" } + ] + } + ] + }, + "set_reasoning_high": { + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "description": "Controls how the agent executes tasks", + "category": "mode", + "type": "select", + "currentValue": "agent", + "options": [ + { "value": "agent", "name": "Agent", "description": "Full agent capabilities with tool access" }, + { "value": "plan", "name": "Plan", "description": "Read-only mode for planning and designing before implementation" }, + { "value": "ask", "name": "Ask", "description": "Q&A mode - no edits or command execution" } + ] + }, + { + "id": "model", + "name": "Model", + "description": "Controls which model is used for responses", + "category": "model", + "type": "select", + "currentValue": "gpt-5", + "options": [ + { "value": "composer-1", "name": "Composer 1" }, + { "value": "gpt-5", "name": "GPT-5" } + ] + }, + { + "id": "reasoning", + "name": "Reasoning", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { "value": "low", "name": "Low" }, + { "value": "medium", "name": "Medium" }, + { "value": "high", "name": "High" } + ] + } + ] + }, + "list_available_models": { + "models": [ + { + "value": "composer-1", + "name": "Composer 1", + "configOptions": [ + { + "id": "fast", + "name": "Fast", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { "value": "false", "name": "Off" }, + { "value": "true", "name": "On" } + ] + } + ] + }, + { + "value": "gpt-5", + "name": "GPT-5", + "configOptions": [ + { + "id": "reasoning", + "name": "Reasoning", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { "value": "low", "name": "Low" }, + { "value": "medium", "name": "Medium" }, + { "value": "high", "name": "High" } + ] + } + ] + } + ] + }, + "session_load": { + "modes": { + "currentModeId": "agent", + "availableModes": [ + { "id": "agent", "name": "Agent", "description": "Full agent capabilities with tool access" }, + { "id": "plan", "name": "Plan", "description": "Read-only mode for planning and designing before implementation" }, + { "id": "ask", "name": "Ask", "description": "Q&A mode - no edits or command execution" } + ] + }, + "models": { + "currentModelId": "composer-1", + "availableModels": [ + { "modelId": "composer-1", "name": "Composer 1" }, + { "modelId": "gpt-5", "name": "GPT-5" } + ] + }, + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "description": "Controls how the agent executes tasks", + "category": "mode", + "type": "select", + "currentValue": "agent", + "options": [ + { "value": "agent", "name": "Agent", "description": "Full agent capabilities with tool access" }, + { "value": "plan", "name": "Plan", "description": "Read-only mode for planning and designing before implementation" }, + { "value": "ask", "name": "Ask", "description": "Q&A mode - no edits or command execution" } + ] + }, + { + "id": "model", + "name": "Model", + "description": "Controls which model is used for responses", + "category": "model", + "type": "select", + "currentValue": "composer-1", + "options": [ + { "value": "composer-1", "name": "Composer 1" }, + { "value": "gpt-5", "name": "GPT-5" } + ] + } + ] + }, + "load_replay": [ + { "sessionUpdate": "user_message_chunk", "content": { "type": "text", "text": "Which database should we use?" } }, + { "sessionUpdate": "agent_thought_chunk", "content": { "type": "text", "text": "The user has not said." } }, + { + "sessionUpdate": "tool_call", + "toolCallId": "replay-0-1", + "title": "Storage", + "kind": "think", + "status": "pending", + "rawInput": { + "_toolName": "askQuestion", + "title": "Storage", + "questions": [ + { "id": "db", "prompt": "Which database?", "options": [ { "id": "pg", "label": "Postgres" }, { "id": "lite", "label": "SQLite" } ], "allowMultiple": false } + ] + } + }, + { "sessionUpdate": "tool_call_update", "toolCallId": "replay-0-1", "status": "completed" }, + { + "sessionUpdate": "tool_call", + "toolCallId": "replay-0-2", + "title": "`ls`", + "kind": "execute", + "status": "pending", + "rawInput": { "command": "ls" } + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "replay-0-2", + "status": "completed", + "rawOutput": { "exitCode": 0, "stdout": "Cargo.toml\n", "stderr": "" } + }, + { + "sessionUpdate": "tool_call", + "toolCallId": "replay-0-3", + "title": "Task: Survey the schema", + "kind": "other", + "status": "pending", + "rawInput": { "_toolName": "task", "prompt": "List every table.", "description": "Survey the schema", "subagentType": "explore" } + }, + { "sessionUpdate": "tool_call_update", "toolCallId": "replay-0-3", "status": "completed", "rawOutput": { "durationMs": 1200, "isBackground": false } }, + { + "sessionUpdate": "subagent_spawned", + "subagentSessionId": "7d3c0f6e-2b1a-4e8f-9c5d-6a4b3e2f1d0c", + "name": "explore", + "task": "Survey the schema", + "capabilities": {}, + "_meta": { "cursor": { "toolCallId": "replay-0-3", "agentId": "7d3c0f6e-2b1a-4e8f-9c5d-6a4b3e2f1d0c" } } + }, + { + "sessionUpdate": "subagent_state_update", + "subagentSessionId": "7d3c0f6e-2b1a-4e8f-9c5d-6a4b3e2f1d0c", + "state": "completed", + "_meta": { "cursor": { "toolCallId": "replay-0-3", "agentId": "7d3c0f6e-2b1a-4e8f-9c5d-6a4b3e2f1d0c" } } + }, + { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "Postgres it is." } } + ], + "available_commands": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { "name": "copy-request-id", "description": "Copy the last request ID to clipboard" }, + { "name": "review", "description": "Review the current diff" } + ] + }, + "turn_tools": [ + { "sessionUpdate": "agent_thought_chunk", "content": { "type": "text", "text": "Check the build first." } }, + { + "sessionUpdate": "tool_call", + "toolCallId": "call_shell_1", + "title": "`cargo build`", + "kind": "execute", + "status": "pending", + "rawInput": { "command": "cargo build" } + }, + { "sessionUpdate": "tool_call_update", "toolCallId": "call_shell_1", "status": "in_progress" }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "call_shell_1", + "status": "completed", + "rawOutput": { "exitCode": 101, "stdout": "", "stderr": "error[E0425]: cannot find value `x`" } + }, + { + "sessionUpdate": "tool_call", + "toolCallId": "call_edit_1", + "title": "Edit `src/lib.rs`", + "kind": "edit", + "status": "pending", + "rawInput": { "path": "src/lib.rs" }, + "locations": [ { "path": "src/lib.rs" } ] + }, + { "sessionUpdate": "tool_call_update", "toolCallId": "call_edit_1", "status": "in_progress" }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "call_edit_1", + "status": "completed", + "content": [ { "type": "diff", "path": "src/lib.rs", "oldText": "let y = x;\n", "newText": "let x = 1;\nlet y = x;\n" } ] + }, + { + "sessionUpdate": "tool_call", + "toolCallId": "call_mcp_1", + "title": "github: create_issue", + "kind": "other", + "status": "pending", + "rawInput": { "providerIdentifier": "github", "toolName": "create_issue", "args": { "title": "Build broke" } } + }, + { "sessionUpdate": "tool_call_update", "toolCallId": "call_mcp_1", "status": "in_progress" }, + { "sessionUpdate": "tool_call_update", "toolCallId": "call_mcp_1", "status": "completed", "rawOutput": { "rejected": true } }, + { + "sessionUpdate": "tool_call", + "toolCallId": "call_grep_1", + "title": "grep \"fn main\"", + "kind": "search", + "status": "pending", + "rawInput": { "pattern": "fn main", "path": "." } + }, + { "sessionUpdate": "tool_call_update", "toolCallId": "call_grep_1", "status": "completed", "rawOutput": { "totalMatches": 1, "truncated": false } }, + { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "The build said \"Error: cannot find value\"; " } }, + { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "I declared it." } } + ], + "run_error": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": "\n\nError: ConnectError: [unavailable] upstream connect error" } + }, + "ask_question": { + "toolCallId": "call_ask_1", + "title": "Storage", + "questions": [ + { + "id": "db", + "prompt": "Which database?", + "options": [ { "id": "pg", "label": "Postgres" }, { "id": "lite", "label": "SQLite" } ], + "allowMultiple": false + }, + { + "id": "extras", + "prompt": "Which extras?", + "options": [ { "id": "cache", "label": "Cache" }, { "id": "queue", "label": "Queue" }, { "id": "search", "label": "Search" } ], + "allowMultiple": true + } + ] + }, + "create_plan_entries": { + "sessionUpdate": "plan", + "entries": [ + { "content": "Add the migration", "priority": "medium", "status": "pending" }, + { "content": "Wire the repository", "priority": "medium", "status": "pending" } + ] + }, + "create_plan": { + "toolCallId": "call_plan_1", + "name": "Storage", + "overview": "Move persistence to Postgres.", + "plan": "# Storage\n\n1. Add the migration\n2. Wire the repository\n", + "todos": [ + { "id": "t1", "content": "Add the migration", "status": "pending" }, + { "id": "t2", "content": "Wire the repository", "status": "pending" } + ] + }, + "update_todos": { + "toolCallId": "call_todos_1", + "todos": [ + { "id": "t1", "content": "Add the migration", "status": "in_progress" }, + { "id": "t2", "content": "Wire the repository", "status": "pending" }, + { "id": "t3", "content": "Drop SQLite", "status": "pending" } + ], + "merge": false + }, + "update_todos_merge": { + "toolCallId": "call_todos_2", + "todos": [ + { "id": "t1", "content": "Add the migration", "status": "completed" }, + { "id": "t3", "content": "Drop SQLite", "status": "cancelled" } + ], + "merge": true + }, + "permission_read": { + "toolCall": { + "toolCallId": "call_read_1", + "title": "Read `/etc/hosts`", + "kind": "read", + "status": "pending" + }, + "options": [ + { "optionId": "allow-once", "name": "Allow once", "kind": "allow_once" }, + { "optionId": "allow-always", "name": "Allow always", "kind": "allow_always" }, + { "optionId": "reject-once", "name": "Reject", "kind": "reject_once" } + ] + }, + "permission_edit": { + "toolCall": { + "toolCallId": "call_write_1", + "title": "Edit `src/lib.rs`", + "kind": "edit", + "status": "pending", + "content": [ { "type": "diff", "path": "src/lib.rs", "oldText": "a\n", "newText": "b\n" } ] + }, + "options": [ + { "optionId": "allow-once", "name": "Allow once", "kind": "allow_once" }, + { "optionId": "allow-always", "name": "Allow always", "kind": "allow_always" }, + { "optionId": "reject-once", "name": "Reject", "kind": "reject_once" } + ] + }, + "permission_shell": { + "toolCall": { + "toolCallId": "call_rm_1", + "title": "`rm -rf target`", + "kind": "execute", + "status": "pending" + }, + "options": [ + { "optionId": "allow-once", "name": "Allow once", "kind": "allow_once" }, + { "optionId": "allow-always", "name": "Allow always", "kind": "allow_always" }, + { "optionId": "reject-once", "name": "Reject", "kind": "reject_once" } + ] + }, + "subagent_turn": [ + { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call_task_1", + "title": "Task: Survey the schema", + "kind": "other", + "status": "pending", + "rawInput": { "_toolName": "task", "prompt": "List every table.", "description": "Survey the schema", "subagentType": "explore" } + } + }, + { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "update": { "sessionUpdate": "tool_call_update", "toolCallId": "call_task_1", "status": "in_progress" } + }, + { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "update": { + "sessionUpdate": "subagent_spawned", + "subagentSessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "name": "explore", + "task": "List every table.", + "capabilities": {}, + "_meta": { "cursor": { "toolCallId": "call_task_1", "agentId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", "model": "composer-1" } } + } + }, + { + "sessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "update": { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "Looking at " } } + }, + { + "sessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "update": { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "the migrations." } } + }, + { + "sessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call_shell_1", + "title": "`ls migrations`", + "kind": "execute", + "status": "pending", + "rawInput": { "command": "ls migrations" } + } + }, + { + "sessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call_shell_1", + "status": "completed", + "rawOutput": { "exitCode": 0, "stdout": "0001_init.sql\n", "stderr": "" } + } + }, + { + "sessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "update": { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "One table: users." } } + }, + { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "update": { + "sessionUpdate": "subagent_state_update", + "subagentSessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "state": "completed", + "_meta": { "cursor": { "toolCallId": "call_task_1", "agentId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", "model": "composer-1" } } + } + }, + { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call_task_1", + "status": "completed", + "rawOutput": { "durationMs": 4200, "isBackground": false } + } + }, + { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "update": { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "The schema has one table." } } + } + ], + "task": { + "toolCallId": "call_task_1", + "description": "Survey the schema", + "prompt": "List every table.", + "subagentType": "explore", + "model": "composer-1", + "agentId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "durationMs": 4200 + } +} diff --git a/crates/agent/tests/fixtures/cursor/status_signed_in.json b/crates/agent/tests/fixtures/cursor/status_signed_in.json new file mode 100644 index 000000000..a0d180298 --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/status_signed_in.json @@ -0,0 +1,10 @@ +{ + "status": "authenticated", + "isAuthenticated": true, + "hasAccessToken": true, + "hasRefreshToken": true, + "userInfo": { + "email": "dev@example.com", + "userId": 4242 + } +} diff --git a/crates/agent/tests/fixtures/cursor/status_signed_out.json b/crates/agent/tests/fixtures/cursor/status_signed_out.json new file mode 100644 index 000000000..5c99c62bc --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/status_signed_out.json @@ -0,0 +1,7 @@ +{ + "status": "unauthenticated", + "isAuthenticated": false, + "hasAccessToken": false, + "hasRefreshToken": false, + "message": "Not logged in" +} diff --git a/crates/agent/tests/fixtures/grok/README.md b/crates/agent/tests/fixtures/grok/README.md new file mode 100644 index 000000000..9d9ea7192 --- /dev/null +++ b/crates/agent/tests/fixtures/grok/README.md @@ -0,0 +1,75 @@ +# Grok session fixtures + +Each fixture is one Grok Build session recorded on the wire in both +directions, in wire order, untruncated: one JSON object per line, +`{"from": "client" | "agent", "message": }`. + +## `resumed_turn.jsonl` + +### Provenance + +- CLI: `grok 1.0.46 (2765805b9442)`, darwin-arm64, in a throwaway `HOME` and + `GROK_HOME`. +- Model: a local scripted stand-in for the xAI Responses API + (`GROK_XAI_API_BASE_URL` / `GROK_CLI_CHAT_PROXY_BASE_URL` pointing at it, + `XAI_API_KEY` a placeholder). No xAI account or live model took part; every + model turn below was scripted, and each model call reports 1,200 input + tokens (1,000 cached) and 34 output tokens. +- Client: Tcode's production session path, through the probe: + + ```sh + cargo run -p agent --example probe -- grok "Count, edit, ask and echo" auto_edits \ + --resume '{"session_id":"01a10095-e65c-7983-a817-6884b010f1ae"}' --effort low \ + --mcp tcode_probe http://127.0.0.1:18433/mcp tcode-secret + ``` + + with a pass-through recorder as `grok` on `PATH`. Grok was launched as + `grok --permission-mode default agent --reasoning-effort low stdio`. + +### Scenario + +An earlier process created the session with one turn; it is not recorded. +This process resumes it (`session/resume`, then `session/set_mode default`) +and runs one turn whose scripted model calls are: + +1. a reasoning summary, then `run_terminal_command` printing three lines one + second apart. Grok asks permission; the probe approves once. +2. `run_terminal_command` `touch quiet.txt`, which Grok runs unprompted and + which prints nothing. +3. `search_replace` in `in.txt`. Grok asks permission; Tcode approves it once + itself (AutoAcceptEdits). +4. `ask_user_question` with two options; the probe answers with the first. +5. `use_tool` calling `echo_upper` on a local HTTP MCP server registered as + `tcode_probe` with a bearer header. Grok asks permission; the probe + approves once. +6. A final message. + +The probe then shuts the session down (`session/close`). + +### Sanitization + +The `initialize` result's `hostname` is `fixture-host` and its `agentId` and +`agentInstanceId` are zero UUIDs; the scratch directory is renamed to +`/tmp/grok-fixture` in all its forms (`/private/tmp/…`, URL-encoded). Nothing +else was changed. The `/Users/admin/actions-runner/…` paths are Grok's own +built-in workflow metadata. + +## `agent_started_turn.jsonl` + +Same CLI, backend and recorder as above, through the probe: + +```sh +cargo run -p agent --example probe -- grok "Background then foreground" full_access \ + --linger 3 --follow-up 4 "And now?" +``` + +Grok was launched as `grok --permission-mode bypassPermissions agent stdio`. +A new session runs one sent turn whose scripted model calls start +`sleep 1; echo bgdone` in the background (`block_until_ms: 0`), run +`sleep 3; echo fg` in the foreground, and answer. The background task +finishes during that turn, so as soon as the turn ends Grok runs a prompt of +its own, `task-completed-` (`_x.ai/queue/changed` reports it running +before the sent turn's `session/prompt` result arrives), and the scripted +model answers it. Four seconds after the first turn completed, the probe sends +a second turn, which the scripted model answers; the probe then closes the +session. Sanitized as above. diff --git a/crates/agent/tests/fixtures/grok/agent_started_turn.jsonl b/crates/agent/tests/fixtures/grok/agent_started_turn.jsonl new file mode 100644 index 000000000..6908dcfa3 --- /dev/null +++ b/crates/agent/tests/fixtures/grok/agent_started_turn.jsonl @@ -0,0 +1,89 @@ +{"from":"client","message":{"jsonrpc":"2.0","id":"11ba930d-a59e-4559-bc51-0ad12db9e1e3","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"auth":{"terminal":false}},"clientInfo":{"name":"tcode","title":"Tcode","version":"0.1.0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"11ba930d-a59e-4559-bc51-0ad12db9e1e3","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"sessionCapabilities":{"list":{},"resume":{},"close":{}},"auth":{},"_meta":{"x.ai/fs_notify":true,"x.ai/hooks":{"blockingEvents":["pre_tool_use","stop","subagent_stop"],"decisions":["deny","block"],"stopSignals":["continue","stopReason","additionalContext"]},"x.ai/capabilities":{"toolOverrides":{"x_keyword_search":true,"x_semantic_search":true,"x_user_search":false,"x_thread_fetch":false}}}},"authMethods":[{"id":"xai.api_key","name":"xai.api_key","description":"XAI_API_KEY or api_key/env_key in config.toml"},{"id":"grok.com","name":"Grok","description":"Sign in with Grok"}],"_meta":{"grokShell":true,"defaultAuthMethodId":"xai.api_key","x.ai/mcp/sdk":true,"x.ai/pluginDirs":true,"currentWorkingDirectory":"/private/tmp/grok-fixture/cwork","agentVersion":"1.0.46","agentId":"00000000-0000-0000-0000-000000000000","agentInstanceId":"00000000-0000-0000-0000-000000000000","hostname":"fixture-host","modelState":{"currentModelId":"grok-4.6","availableModels":[{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High Effort","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High Effort","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium Effort","description":"Balanced effort","default":false},{"id":"low","value":"low","label":"Low Effort","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan"}}]},"mcpServers":[],"mcpApps":false,"metadata":null,"availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}}],"cancelRewind":true,"sessionRecap":true,"feedbackTraceOffer":false,"voiceMode":true}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/servers_updated","params":{"mcpServers":[]}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"2232a326-e5f6-41d0-9a1a-ffc78d098230","method":"authenticate","params":{"methodId":"xai.api_key"}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"2232a326-e5f6-41d0-9a1a-ffc78d098230","result":{}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"68966399-2fd4-49a6-bb17-57e9e932c5cf","method":"session/new","params":{"cwd":"/tmp/grok-fixture/cwork","mcpServers":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"auth","sessionId":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"resolve_workspace","sessionId":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"folder_trust","sessionId":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"plugin_registry","sessionId":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"mcp_merge","sessionId":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"persistence_init","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"spawn_session_actor","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"agent_build","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"68966399-2fd4-49a6-bb17-57e9e932c5cf","result":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","models":{"currentModelId":"grok-4.6","availableModels":[{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High Effort","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High Effort","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium Effort","description":"Balanced effort","default":false},{"id":"low","value":"low","label":"Low Effort","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan"}}]},"configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"grok-4.6","options":[{"value":"grok-4.6","name":"Grok 4.6"},{"value":"grok-4.5","name":"Grok 4.5"}]},{"id":"reasoning_effort","name":"Reasoning Effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"xhigh","name":"Extra High Effort","description":"Highest effort and reasoning level"},{"value":"high","name":"High Effort","description":"Higher implementation quality with extensive reasoning"},{"value":"medium","name":"Medium Effort","description":"Balanced effort"},{"value":"low","name":"Low Effort","description":"Quick, fast implementations"}]}],"_meta":{"currentWorkingDirectory":"/tmp/grok-fixture/cwork","codebaseIndexed":[],"isGitRepo":false,"gitRoot":null,"showNonGitWarning":false,"feedbackEnabled":true,"x.ai/sessionConfig":{"options":[{"id":"grok-4.6","category":"model","label":"Grok 4.6","selected":true},{"id":"grok-4.5","category":"model","label":"Grok 4.5","selected":false},{"id":"xhigh","category":"mode","label":"Extra High Effort","description":"Highest effort and reasoning level","selected":false},{"id":"high","category":"mode","label":"High Effort","description":"Higher implementation quality with extensive reasoning","selected":true},{"id":"medium","category":"mode","label":"Medium Effort","description":"Balanced effort","selected":false},{"id":"low","category":"mode","label":"Low Effort","description":"Quick, fast implementations","selected":false}]},"x.ai/sessionDetail":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","kind":"build","cwd":"/tmp/grok-fixture/cwork","currentModelId":"grok-4.6"},"x.ai/memoryMode":"legacy"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"git_discovery","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"finalize_response","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"tool_overrides","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp_initialized","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","mcpToolCount":0,"elapsedMs":0}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"f0494724-593c-4d5c-847d-882a6a7952ff","method":"session/prompt","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","prompt":[{"type":"text","text":"Background then foreground"}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1358,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-0","agentTimestampMs":1791014090576,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1358,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-1","agentTimestampMs":1791014090580,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","title":null,"cwd":"/tmp/grok-fixture/cwork","isWorktree":false,"modelId":"grok-4.6","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1791014090580,"origin":{"kind":"local"}}],"removed":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[{"id":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","version":0,"kind":"prompt","text":"Background then foreground","position":0}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[],"runningPromptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","runningText":"Background then foreground","runningKind":"prompt"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"session_summary_generated","session_summary":"Mock title"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"session_info_update","title":"Mock title"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_68_0","tool_index":0,"name":"run_terminal_command"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"command\":\"sleep 1; echo bgdone\",\"description\":\"Background sleep\",\"block_until_ms\":0}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-4","agentTimestampMs":1791014090679,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090677,"turnStartMs":1791014090656,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call","toolCallId":"call_68_0","title":"run_terminal_command","rawInput":{"command":"sleep 1; echo bgdone","description":"Background sleep","block_until_ms":0},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-5","agentTimestampMs":1791014090679,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090677,"turnStartMs":1791014090656,"updateType":"ToolCall","updateParams":{"toolCallId":"call_68_0","title":"run_terminal_command","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_68_0","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_68_0","kind":"execute","title":"Execute `sleep 1; echo bgdone`","content":[{"type":"content","content":{"type":"text","text":"Background sleep"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 1; echo bgdone","description":"Background sleep","is_background":false,"block_until_ms":0},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false,"input":{"command":"sleep 1; echo bgdone","description":"Background sleep"}}}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-6","agentTimestampMs":1791014090679,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090677,"turnStartMs":1791014090656,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_68_0","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_68_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/task_backgrounded","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"task_backgrounded","tool_call_id":"call_68_0","task_id":"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","command":"sleep 1; echo bgdone","cwd":"/tmp/grok-fixture/cwork","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a100c2-3711-75f1-93b7-1d55dd741155/terminal/call_68_0.log","description":"Background sleep"},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-7","agentTimestampMs":1791014090693}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"background_tasks","tasks":[{"task_id":"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","command":"sleep 1; echo bgdone","description":"Background sleep","cwd":"/tmp/grok-fixture/cwork","kind":"bash","status":"running","started_at":"2026-10-03T07:54:50.693430+00:00","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a100c2-3711-75f1-93b7-1d55dd741155/terminal/call_68_0.log"}]},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-9","agentTimestampMs":1791014090693}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_68_0","status":"completed","title":"[bg] sleep 1; echo bgdone (01a100c2)","content":[{"type":"content","content":{"type":"text","text":"Background task 01a100c2-37c5-7fc2-b92a-c0f8b2c3659b started"}}],"rawOutput":{"type":"BackgroundTaskStarted","task_id":"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","task_type":"bash","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a100c2-3711-75f1-93b7-1d55dd741155/terminal/call_68_0.log","status":"running","command":"sleep 1; echo bgdone","summary":"Background task 01a100c2-37c5-7fc2-b92a-c0f8b2c3659b started","retrieval_hint":"Use get_command_or_subagent_output with task_ids=[\"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b\"] when you need the output.","pid":67305}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-10","agentTimestampMs":1791014090697,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090677,"turnStartMs":1791014090656,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_68_0","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_69_0","tool_index":0,"name":"run_terminal_command"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"command\":\"sleep 3; echo fg\",\"description\":\"Foreground sleep\"}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-13","agentTimestampMs":1791014090712,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090709,"turnStartMs":1791014090656,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call","toolCallId":"call_69_0","title":"run_terminal_command","rawInput":{"command":"sleep 3; echo fg","description":"Foreground sleep"},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-14","agentTimestampMs":1791014090715,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090709,"turnStartMs":1791014090656,"updateType":"ToolCall","updateParams":{"toolCallId":"call_69_0","title":"run_terminal_command","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_69_0","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_69_0","kind":"execute","title":"Execute `sleep 3; echo fg`","content":[{"type":"content","content":{"type":"text","text":"Foreground sleep"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 3; echo fg","description":"Foreground sleep","is_background":false},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false,"input":{"command":"sleep 3; echo fg","description":"Foreground sleep"}}}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-15","agentTimestampMs":1791014090715,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090709,"turnStartMs":1791014090656,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_69_0","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_69_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_69_0","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"","exit_code":0,"command":"sleep 3; echo fg","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":0}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_68_0","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"bgdone\n"}}],"rawOutput":{"type":"Bash","output":[98,103,100,111,110,101,10],"output_for_prompt":"bgdone\n","exit_code":0,"command":"sleep 1; echo bgdone","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":7}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[],"runningPromptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","runningText":"Background then foreground","runningKind":"prompt"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/task_completed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"task_completed","task_snapshot":{"task_id":"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","command":"sleep 1; echo bgdone","cwd":"/tmp/grok-fixture/cwork","start_time":{"secs_since_epoch":1791014090,"nanos_since_epoch":693430000},"end_time":{"secs_since_epoch":1791014091,"nanos_since_epoch":713158000},"output":"bgdone\n","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a100c2-3711-75f1-93b7-1d55dd741155/terminal/call_68_0.log","truncated":false,"output_total_bytes":7,"exit_code":0,"signal":null,"completed":true,"kind":"bash","block_waited":false,"explicitly_killed":false,"kill_result_delivered":false,"owner_session_id":"01a100c2-3711-75f1-93b7-1d55dd741155","description":"Background sleep","is_backgrounded":true},"will_wake":true},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-16","agentTimestampMs":1791014091757}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"background_tasks","tasks":[{"task_id":"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","command":"sleep 1; echo bgdone","description":"Background sleep","cwd":"/tmp/grok-fixture/cwork","kind":"bash","status":"completed","started_at":"2026-10-03T07:54:50.693430+00:00","ended_at":"2026-10-03T07:54:51.713158+00:00","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a100c2-3711-75f1-93b7-1d55dd741155/terminal/call_68_0.log","exit_code":0}]},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-18","agentTimestampMs":1791014091758}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_69_0","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"fg\n"}}],"rawOutput":{"type":"Bash","output":[102,103,10],"output_for_prompt":"fg\n","exit_code":0,"command":"sleep 3; echo fg","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":3}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_69_0","status":"completed","content":[{"type":"content","content":{"type":"text","text":"fg\n"}}],"rawOutput":{"type":"Bash","output":[102,103,10],"output_for_prompt":"exit: 0\nfg\n","exit_code":0,"command":"sleep 3; echo fg","truncated":false,"signal":null,"timed_out":false,"description":"Foreground sleep","current_dir":"/tmp/grok-fixture/cwork","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a100c2-3711-75f1-93b7-1d55dd741155/terminal/call_69_0.log","total_bytes":3}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-19","agentTimestampMs":1791014093746,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090709,"turnStartMs":1791014090656,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_69_0","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"User tu"}},"_meta":{"totalTokens":1236,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-22","agentTimestampMs":1791014093763,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014093763,"turnStartMs":1791014090656,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"rn done."}},"_meta":{"totalTokens":1236,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-23","agentTimestampMs":1791014093823,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014093763,"turnStartMs":1791014090656,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-24","agentTimestampMs":1791014093880,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014093763,"turnStartMs":1791014090656,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"turn_completed","prompt_id":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","stop_reason":"end_turn","usage":{"inputTokens":3600,"outputTokens":102,"totalTokens":3702,"cachedReadTokens":3000,"cacheCreationTokens":0,"reasoningTokens":36,"modelCalls":3,"apiDurationMs":148,"modelUsage":{"grok-4.6":{"inputTokens":3600,"outputTokens":102,"totalTokens":3702,"cachedReadTokens":3000,"cacheCreationTokens":0,"reasoningTokens":36,"modelCalls":3,"apiDurationMs":148}},"numTurns":3},"elapsed_ms":3358},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-27","agentTimestampMs":1791014093942}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[],"runningPromptId":"task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","runningText":"\nBackground task \"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b\" completed (exit code: 0).\nDescription: Background sleep | Duration: 1.0s\nUse get_command_or_subagent_output(\"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b\") to see the full output.\n","runningKind":"prompt"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/prompt_complete","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","stopReason":"end_turn","agentResult":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","title":null,"cwd":"/tmp/grok-fixture/cwork","isWorktree":false,"modelId":"grok-4.6","reasoningEffort":"high","yolo":true,"activity":"idle","resident":true,"lastChangeUnixMs":1791014093942,"origin":{"kind":"local"}}],"removed":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"f0494724-593c-4d5c-847d-882a6a7952ff","result":{"stopReason":"end_turn","_meta":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","requestId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","totalTokens":1234,"modelId":"grok-4.6","inputTokens":1200,"outputTokens":34,"cachedReadTokens":1000,"reasoningTokens":12,"usage":{"inputTokens":3600,"outputTokens":102,"totalTokens":3702,"cachedReadTokens":3000,"cacheCreationTokens":0,"reasoningTokens":36,"modelCalls":3,"apiDurationMs":148,"modelUsage":{"grok-4.6":{"inputTokens":3600,"outputTokens":102,"totalTokens":3702,"cachedReadTokens":3000,"cacheCreationTokens":0,"reasoningTokens":36,"modelCalls":3,"apiDurationMs":148}},"numTurns":3}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The background ta"}},"_meta":{"totalTokens":1299,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-30","agentTimestampMs":1791014093987,"promptId":"task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","streamStartMs":1791014093987,"turnStartMs":1791014093983,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"sk printed bgdone."}},"_meta":{"totalTokens":1299,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-31","agentTimestampMs":1791014094045,"promptId":"task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","streamStartMs":1791014093987,"turnStartMs":1791014093983,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-32","agentTimestampMs":1791014094105,"promptId":"task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","streamStartMs":1791014093987,"turnStartMs":1791014093983,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"turn_completed","prompt_id":"task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","stop_reason":"end_turn","usage":{"inputTokens":1200,"outputTokens":34,"totalTokens":1234,"cachedReadTokens":1000,"cacheCreationTokens":0,"reasoningTokens":12,"modelCalls":1,"apiDurationMs":120,"modelUsage":{"grok-4.6":{"inputTokens":1200,"outputTokens":34,"totalTokens":1234,"cachedReadTokens":1000,"cacheCreationTokens":0,"reasoningTokens":12,"modelCalls":1,"apiDurationMs":120}},"numTurns":1},"elapsed_ms":227},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-35","agentTimestampMs":1791014094169}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"last_turn_summary","summary":"Aux title","prompt_id":"task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b"},"_meta":{"agentTimestampMs":1791014094286}}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"e32888c3-3a4a-4751-8a2c-f5081ea45849","method":"session/prompt","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","prompt":[{"type":"text","text":"And now?"}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","title":null,"cwd":"/tmp/grok-fixture/cwork","isWorktree":false,"modelId":"grok-4.6","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1791014097953,"origin":{"kind":"local"}}],"removed":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[{"id":"76953359-ff4d-4396-97ac-696acb050772","version":0,"kind":"prompt","text":"And now?","position":0}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[],"runningPromptId":"76953359-ff4d-4396-97ac-696acb050772","runningText":"And now?","runningKind":"prompt"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Follow-u"}},"_meta":{"totalTokens":1242,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-38","agentTimestampMs":1791014097987,"promptId":"76953359-ff4d-4396-97ac-696acb050772","streamStartMs":1791014097987,"turnStartMs":1791014097982,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"p reply."}},"_meta":{"totalTokens":1242,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-39","agentTimestampMs":1791014098047,"promptId":"76953359-ff4d-4396-97ac-696acb050772","streamStartMs":1791014097987,"turnStartMs":1791014097982,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-40","agentTimestampMs":1791014098107,"promptId":"76953359-ff4d-4396-97ac-696acb050772","streamStartMs":1791014097987,"turnStartMs":1791014097982,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"turn_completed","prompt_id":"76953359-ff4d-4396-97ac-696acb050772","stop_reason":"end_turn","usage":{"inputTokens":1200,"outputTokens":34,"totalTokens":1234,"cachedReadTokens":1000,"cacheCreationTokens":0,"reasoningTokens":12,"modelCalls":1,"apiDurationMs":123,"modelUsage":{"grok-4.6":{"inputTokens":1200,"outputTokens":34,"totalTokens":1234,"cachedReadTokens":1000,"cacheCreationTokens":0,"reasoningTokens":12,"modelCalls":1,"apiDurationMs":123}},"numTurns":1},"elapsed_ms":214},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-43","agentTimestampMs":1791014098168}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/prompt_complete","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","promptId":"76953359-ff4d-4396-97ac-696acb050772","stopReason":"end_turn","agentResult":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","title":null,"cwd":"/tmp/grok-fixture/cwork","isWorktree":false,"modelId":"grok-4.6","reasoningEffort":"high","yolo":true,"activity":"idle","resident":true,"lastChangeUnixMs":1791014098169,"origin":{"kind":"local"}}],"removed":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"e32888c3-3a4a-4751-8a2c-f5081ea45849","result":{"stopReason":"end_turn","_meta":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","requestId":"76953359-ff4d-4396-97ac-696acb050772","promptId":"76953359-ff4d-4396-97ac-696acb050772","totalTokens":1234,"modelId":"grok-4.6","inputTokens":1200,"outputTokens":34,"cachedReadTokens":1000,"reasoningTokens":12,"usage":{"inputTokens":1200,"outputTokens":34,"totalTokens":1234,"cachedReadTokens":1000,"cacheCreationTokens":0,"reasoningTokens":12,"modelCalls":1,"apiDurationMs":123,"modelUsage":{"grok-4.6":{"inputTokens":1200,"outputTokens":34,"totalTokens":1234,"cachedReadTokens":1000,"cacheCreationTokens":0,"reasoningTokens":12,"modelCalls":1,"apiDurationMs":123}},"numTurns":1}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"last_turn_summary","summary":"Aux title","prompt_id":"76953359-ff4d-4396-97ac-696acb050772"},"_meta":{"agentTimestampMs":1791014098280}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"session_summary_generated","session_summary":"Aux title"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"session_info_update","title":"Aux title"}}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"1c82d47f-06e3-4730-82d2-139c1995e8e7","method":"session/close","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[],"removed":["01a100c2-3711-75f1-93b7-1d55dd741155"]}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"1c82d47f-06e3-4730-82d2-139c1995e8e7","result":{"_meta":{"x.ai/closeOutcome":"closed"}}}} diff --git a/crates/agent/tests/fixtures/grok/resumed_turn.jsonl b/crates/agent/tests/fixtures/grok/resumed_turn.jsonl new file mode 100644 index 000000000..a68abc465 --- /dev/null +++ b/crates/agent/tests/fixtures/grok/resumed_turn.jsonl @@ -0,0 +1,95 @@ +{"from":"client","message":{"jsonrpc":"2.0","id":"bfa3a825-2220-4cfa-8da8-d43ddd241a0b","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"auth":{"terminal":false}},"clientInfo":{"name":"tcode","title":"Tcode","version":"0.1.0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"bfa3a825-2220-4cfa-8da8-d43ddd241a0b","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"sessionCapabilities":{"list":{},"resume":{},"close":{}},"auth":{},"_meta":{"x.ai/fs_notify":true,"x.ai/hooks":{"blockingEvents":["pre_tool_use","stop","subagent_stop"],"decisions":["deny","block"],"stopSignals":["continue","stopReason","additionalContext"]},"x.ai/capabilities":{"toolOverrides":{"x_keyword_search":true,"x_semantic_search":true,"x_user_search":false,"x_thread_fetch":false}}}},"authMethods":[{"id":"xai.api_key","name":"xai.api_key","description":"XAI_API_KEY or api_key/env_key in config.toml"},{"id":"grok.com","name":"Grok","description":"Sign in with Grok"}],"_meta":{"grokShell":true,"defaultAuthMethodId":"xai.api_key","x.ai/mcp/sdk":true,"x.ai/pluginDirs":true,"currentWorkingDirectory":"/private/tmp/grok-fixture/cwork","agentVersion":"1.0.46","agentId":"00000000-0000-0000-0000-000000000000","agentInstanceId":"00000000-0000-0000-0000-000000000000","hostname":"fixture-host","modelState":{"currentModelId":"grok-4.6","availableModels":[{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"low","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High Effort","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High Effort","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium Effort","description":"Balanced effort","default":false},{"id":"low","value":"low","label":"Low Effort","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan"}}]},"mcpServers":[],"mcpApps":false,"metadata":null,"availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}}],"cancelRewind":true,"sessionRecap":true,"feedbackTraceOffer":false,"voiceMode":true}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/servers_updated","params":{"mcpServers":[]}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"c4c00abd-59ac-4ec0-88f4-5961604d10d9","method":"authenticate","params":{"methodId":"xai.api_key"}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"c4c00abd-59ac-4ec0-88f4-5961604d10d9","result":{}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"719e6cdf-86e3-45e8-9956-04007a0b87a0","method":"session/resume","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","cwd":"/tmp/grok-fixture/cwork","mcpServers":[{"type":"http","name":"tcode_probe","url":"http://127.0.0.1:18433/mcp","headers":[{"name":"Authorization","value":"Bearer tcode-secret"}]}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/init_progress","params":{"total":1,"connected":0,"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":2012,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-0","agentTimestampMs":1791011186992,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/init_progress","params":{"total":1,"connected":1,"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp_initialized","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","mcpToolCount":1,"elapsedMs":15}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"background_tasks","tasks":[]},"_meta":{"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-2","agentTimestampMs":1791011187007}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"model_changed","model_id":"grok-4.6","reasoning_effort":"high"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"719e6cdf-86e3-45e8-9956-04007a0b87a0","result":{"models":{"currentModelId":"grok-4.6","availableModels":[{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High Effort","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High Effort","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium Effort","description":"Balanced effort","default":false},{"id":"low","value":"low","label":"Low Effort","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan"}}]},"configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"grok-4.6","options":[{"value":"grok-4.6","name":"Grok 4.6"},{"value":"grok-4.5","name":"Grok 4.5"}]},{"id":"reasoning_effort","name":"Reasoning Effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"xhigh","name":"Extra High Effort","description":"Highest effort and reasoning level"},{"value":"high","name":"High Effort","description":"Higher implementation quality with extensive reasoning"},{"value":"medium","name":"Medium Effort","description":"Balanced effort"},{"value":"low","name":"Low Effort","description":"Quick, fast implementations"}]}],"_meta":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","codebaseIndexed":[],"x.ai/sessionConfig":{"options":[{"id":"grok-4.6","category":"model","label":"Grok 4.6","selected":true},{"id":"grok-4.5","category":"model","label":"Grok 4.5","selected":false},{"id":"xhigh","category":"mode","label":"Extra High Effort","description":"Highest effort and reasoning level","selected":false},{"id":"high","category":"mode","label":"High Effort","description":"Higher implementation quality with extensive reasoning","selected":true},{"id":"medium","category":"mode","label":"Medium Effort","description":"Balanced effort","selected":false},{"id":"low","category":"mode","label":"Low Effort","description":"Quick, fast implementations","selected":false}]},"x.ai/sessionDetail":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","kind":"build","cwd":"/tmp/grok-fixture/cwork","currentModelId":"grok-4.6","title":"Mock title"},"x.ai/memoryMode":"legacy"}}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"56e9049d-5a19-49d1-98f8-948541cd67c2","method":"session/set_mode","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","modeId":"default"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":2012,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-3","agentTimestampMs":1791011187011,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"56e9049d-5a19-49d1-98f8-948541cd67c2","result":{}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"440d270b-6902-4b29-9ab9-572241521340","method":"session/prompt","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","prompt":[{"type":"text","text":"Count, edit, ask and echo"}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","title":null,"cwd":"/tmp/grok-fixture/cwork","isWorktree":false,"modelId":"grok-4.6","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1791011187018,"origin":{"kind":"local"}}],"removed":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","entries":[{"id":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","version":0,"kind":"prompt","text":"Count, edit, ask and echo","position":0}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","entries":[],"runningPromptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","runningText":"Count, edit, ask and echo","runningKind":"prompt"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/server_status","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","name":"tcode_probe","source":"local","status":"ready","reason":"initialized","tools":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Countin"}},"_meta":{"totalTokens":2139,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-6","agentTimestampMs":1791011187099,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011187099,"turnStartMs":1791011187089,"updateType":"AgentThoughtChunk","chunkId":1}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"g first."}},"_meta":{"totalTokens":2139,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-7","agentTimestampMs":1791011187159,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011187099,"turnStartMs":1791011187089,"updateType":"AgentThoughtChunk","chunkId":2}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_33_1","tool_index":0,"name":"run_terminal_command"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"command\":\"for i in 1 2 3; do echo line$i; sleep 1; done\",\"description\":\"Print three lines slowly\"}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-8","agentTimestampMs":1791011187215,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011187099,"turnStartMs":1791011187089,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call","toolCallId":"call_33_1","title":"run_terminal_command","rawInput":{"command":"for i in 1 2 3; do echo line$i; sleep 1; done","description":"Print three lines slowly"},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-9","agentTimestampMs":1791011187215,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011187099,"turnStartMs":1791011187089,"updateType":"ToolCall","updateParams":{"toolCallId":"call_33_1","title":"run_terminal_command","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_33_1","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_33_1","kind":"execute","title":"Execute `for i in 1 2 3; do echo line$i; sleep 1; done`","content":[{"type":"content","content":{"type":"text","text":"Print three lines slowly"}}],"locations":[],"rawInput":{"variant":"Bash","command":"for i in 1 2 3; do echo line$i; sleep 1; done","description":"Print three lines slowly","is_background":false},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false,"input":{"command":"for i in 1 2 3; do echo line$i; sleep 1; done","description":"Print three lines slowly"}}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-10","agentTimestampMs":1791011187215,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011187099,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_33_1","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","toolCall":{"toolCallId":"call_33_1","kind":"execute","title":"Execute `for i in 1 2 3; do echo line$i; sleep 1; done`","rawInput":{"variant":"Bash","command":"for i in 1 2 3; do echo line$i; sleep 1; done","description":"Print three lines slowly","is_background":false},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false,"input":{"command":"for i in 1 2 3; do echo line$i; sleep 1; done","description":"Print three lines slowly"}}}},"options":[{"optionId":"always-allow","name":"Yes, and don't ask again for bash commands","kind":"allow_always"},{"optionId":"allow-once","name":"Yes, proceed","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"},{"optionId":"reject-always","name":"No, and don't ask again for this command","kind":"reject_always"}]}}} +{"from":"client","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_33_1"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_33_1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"","exit_code":0,"command":"for i in 1 2 3; do echo line$i; sleep 1; done","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":0}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_33_1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"line1\n"}}],"rawOutput":{"type":"Bash","output":[108,105,110,101,49,10],"output_for_prompt":"line1\n","exit_code":0,"command":"for i in 1 2 3; do echo line$i; sleep 1; done","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":6}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_33_1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"line1\nline2\n"}}],"rawOutput":{"type":"Bash","output":[108,105,110,101,49,10,108,105,110,101,50,10],"output_for_prompt":"line1\nline2\n","exit_code":0,"command":"for i in 1 2 3; do echo line$i; sleep 1; done","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_33_1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"line1\nline2\nline3\n"}}],"rawOutput":{"type":"Bash","output":[108,105,110,101,49,10,108,105,110,101,50,10,108,105,110,101,51,10],"output_for_prompt":"line1\nline2\nline3\n","exit_code":0,"command":"for i in 1 2 3; do echo line$i; sleep 1; done","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":18}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_33_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"line1\nline2\nline3\n"}}],"rawOutput":{"type":"Bash","output":[108,105,110,101,49,10,108,105,110,101,50,10,108,105,110,101,51,10],"output_for_prompt":"exit: 0\nline1\nline2\nline3\n","exit_code":0,"command":"for i in 1 2 3; do echo line$i; sleep 1; done","truncated":false,"signal":null,"timed_out":false,"description":"Print three lines slowly","current_dir":"/tmp/grok-fixture/cwork","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a10095-e65c-7983-a817-6884b010f1ae/terminal/call_33_1.log","total_bytes":18}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-11","agentTimestampMs":1791011190293,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011187099,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_33_1","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_34_0","tool_index":0,"name":"run_terminal_command"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"command\":\"touch quiet.txt\",\"description\":\"Create an empty file\"}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-14","agentTimestampMs":1791011190306,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190304,"turnStartMs":1791011187089,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call","toolCallId":"call_34_0","title":"run_terminal_command","rawInput":{"command":"touch quiet.txt","description":"Create an empty file"},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-15","agentTimestampMs":1791011190306,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190304,"turnStartMs":1791011187089,"updateType":"ToolCall","updateParams":{"toolCallId":"call_34_0","title":"run_terminal_command","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_34_0","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_34_0","kind":"execute","title":"Execute `touch quiet.txt`","content":[{"type":"content","content":{"type":"text","text":"Create an empty file"}}],"locations":[],"rawInput":{"variant":"Bash","command":"touch quiet.txt","description":"Create an empty file","is_background":false},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false,"input":{"command":"touch quiet.txt","description":"Create an empty file"}}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-16","agentTimestampMs":1791011190306,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190304,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_34_0","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_34_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_34_0","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"","exit_code":0,"command":"touch quiet.txt","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":0}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_34_0","status":"completed","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"exit: 0\n","exit_code":0,"command":"touch quiet.txt","truncated":false,"signal":null,"timed_out":false,"description":"Create an empty file","current_dir":"/tmp/grok-fixture/cwork","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a10095-e65c-7983-a817-6884b010f1ae/terminal/call_34_0.log","total_bytes":0}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-17","agentTimestampMs":1791011190336,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190304,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_34_0","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_35_0","tool_index":0,"name":"search_replace"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"file_path\":\"in.txt\",\"old_string\":\"hello\",\"new_string\":\"bye\"}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-20","agentTimestampMs":1791011190354,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190352,"turnStartMs":1791011187089,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call","toolCallId":"call_35_0","title":"search_replace","rawInput":{"file_path":"in.txt","old_string":"hello","new_string":"bye"},"_meta":{"x.ai/tool":{"version":1,"name":"search_replace","kind":"edit","namespace":"grok_build","label":"Edit","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-21","agentTimestampMs":1791011190356,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190352,"turnStartMs":1791011187089,"updateType":"ToolCall","updateParams":{"toolCallId":"call_35_0","title":"search_replace","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_35_0","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_35_0","kind":"edit","title":"Edit `in.txt`","content":[{"type":"diff","path":"/tmp/grok-fixture/cwork/in.txt","oldText":"hello","newText":"bye","_meta":{"old_line":1,"new_line":1}}],"locations":[{"path":"in.txt"}],"rawInput":{"variant":"SearchReplace","file_path":"in.txt","old_string":"hello","new_string":"bye","replace_all":false},"_meta":{"x.ai/tool":{"version":1,"name":"search_replace","kind":"edit","namespace":"grok_build","label":"Edit","read_only":false,"input":{"path":"in.txt"}}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-22","agentTimestampMs":1791011190356,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190352,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_35_0","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","toolCall":{"toolCallId":"call_35_0","kind":"edit","title":"Edit `in.txt`","rawInput":{"variant":"SearchReplace","file_path":"in.txt","old_string":"hello","new_string":"bye","replace_all":false},"_meta":{"x.ai/tool":{"version":1,"name":"search_replace","kind":"edit","namespace":"grok_build","label":"Edit","read_only":false,"input":{"path":"in.txt"}}}},"options":[{"optionId":"allow-edits-session","name":"Yes, allow all edits during this session","kind":"allow_always"},{"optionId":"allow-once","name":"Yes","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"}]}}} +{"from":"client","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_35_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_35_0","status":"completed","content":[{"type":"diff","path":"/private/tmp/grok-fixture/cwork/in.txt","oldText":"hello","newText":"bye","_meta":{"details":[{"old_string":"hello","old_line":1,"new_string":"bye","new_line":1,"context_before":"","context_after":"","line_prefix":""}]}}],"rawOutput":{"type":"SearchReplace","EditsApplied":{"old_string":"hello","new_string":"bye","tool_output_for_prompt":"The file in.txt has been updated successfully.","tool_output_for_prompt_concise":"The file in.txt has been updated.","absolute_path":"/private/tmp/grok-fixture/cwork/in.txt","edits":{"details":[{"old_string":"hello","old_line":1,"new_string":"bye","new_line":1,"context_before":"","context_after":"","line_prefix":""}]}}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-23","agentTimestampMs":1791011190380,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190352,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_35_0","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_36_0","tool_index":0,"name":"ask_user_question"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"questions\":[{\"question\":\"Which greeting should I use?\",\"options\":[{\"label\":\"Hello\",\"description\":\"Friendly\"},{\"label\":\"Hi\",\"description\":\"Short\"}]}]}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-26","agentTimestampMs":1791011190395,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190394,"turnStartMs":1791011187089,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call","toolCallId":"call_36_0","title":"ask_user_question","rawInput":{"questions":[{"question":"Which greeting should I use?","options":[{"label":"Hello","description":"Friendly"},{"label":"Hi","description":"Short"}]}]},"_meta":{"x.ai/tool":{"version":1,"name":"ask_user_question","kind":"ask_user","namespace":"grok_build","label":"Ask User","read_only":true}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-27","agentTimestampMs":1791011190398,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190394,"turnStartMs":1791011187089,"updateType":"ToolCall","updateParams":{"toolCallId":"call_36_0","title":"ask_user_question","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_36_0","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_36_0","kind":"other","title":"Ask: Which greeting should I use?","locations":[],"rawInput":{"variant":"AskUserQuestion","questions":[{"question":"Which greeting should I use?","options":[{"label":"Hello","description":"Friendly"},{"label":"Hi","description":"Short"}],"multiSelect":null}]},"_meta":{"x.ai/tool":{"version":1,"name":"ask_user_question","kind":"ask_user","namespace":"grok_build","label":"Ask User","read_only":true}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-28","agentTimestampMs":1791011190398,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190394,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_36_0","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_36_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_36_0","kind":"question"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":2,"method":"_x.ai/ask_user_question","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","toolCallId":"call_36_0","questions":[{"question":"Which greeting should I use?","options":[{"label":"Hello","description":"Friendly"},{"label":"Hi","description":"Short"}],"multiSelect":null}],"mode":"default"}}} +{"from":"client","message":{"jsonrpc":"2.0","id":2,"result":{"outcome":"accepted","answers":{"Which greeting should I use?":["Hello"]}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_36_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_36_0","status":"completed","content":[{"type":"content","content":{"type":"text","text":"User has answered your questions: \"Which greeting should I use?\"=\"Hello\". You can now continue with the user's answers in mind."}}],"rawOutput":{"type":"AskUserQuestion","UserAnswered":{"message":"User has answered your questions: \"Which greeting should I use?\"=\"Hello\". You can now continue with the user's answers in mind."}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-29","agentTimestampMs":1791011190415,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190394,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_36_0","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_37_0","tool_index":0,"name":"use_tool"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"tool_name\":\"tcode_probe__echo_upper\",\"tool_input\":{\"text\":\"hello mcp\"}}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-32","agentTimestampMs":1791011190438,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190436,"turnStartMs":1791011187089,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call","toolCallId":"call_37_0","title":"use_tool","rawInput":{"tool_name":"tcode_probe__echo_upper","tool_input":{"text":"hello mcp"}},"_meta":{"x.ai/tool":{"version":1,"name":"use_tool","kind":"use_tool","namespace":"grok_build","label":"Use Tool","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-33","agentTimestampMs":1791011190440,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190436,"turnStartMs":1791011187089,"updateType":"ToolCall","updateParams":{"toolCallId":"call_37_0","title":"use_tool","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_37_0","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_37_0","kind":"other","title":"tcode_probe__echo_upper","locations":[],"rawInput":{"variant":"UseTool","tool_name":"tcode_probe__echo_upper","tool_input":{"text":"hello mcp"}},"_meta":{"x.ai/tool":{"version":1,"name":"use_tool","kind":"use_tool","namespace":"grok_build","label":"Use Tool","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-34","agentTimestampMs":1791011190440,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190436,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_37_0","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":3,"method":"session/request_permission","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","toolCall":{"toolCallId":"call_37_0","kind":"other","title":"tcode_probe__echo_upper","rawInput":{"variant":"UseTool","tool_name":"tcode_probe__echo_upper","tool_input":{"text":"hello mcp"}},"_meta":{"x.ai/tool":{"version":1,"name":"use_tool","kind":"use_tool","namespace":"grok_build","label":"Use Tool","read_only":false}}},"options":[{"optionId":"always-allow","name":"always allow","kind":"allow_always"},{"optionId":"allow-once","name":"allow once","kind":"allow_once"},{"optionId":"reject-once","name":"reject once","kind":"reject_once"}]}}} +{"from":"client","message":{"jsonrpc":"2.0","id":3,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_37_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_37_0","status":"completed","rawOutput":{"type":"MCP","tool_name":"echo_upper","server_name":"tcode_probe","output":{"OkayOutput":"HELLO MCP"}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-35","agentTimestampMs":1791011190464,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190436,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_37_0","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Counted, edited, "}},"_meta":{"totalTokens":1236,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-38","agentTimestampMs":1791011190482,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190479,"turnStartMs":1791011187089,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"asked and echoed."}},"_meta":{"totalTokens":1236,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-39","agentTimestampMs":1791011190540,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190479,"turnStartMs":1791011187089,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-40","agentTimestampMs":1791011190601,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190479,"turnStartMs":1791011187089,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","entries":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"turn_completed","prompt_id":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","stop_reason":"end_turn","usage":{"inputTokens":7200,"outputTokens":204,"totalTokens":7404,"cachedReadTokens":6000,"cacheCreationTokens":0,"reasoningTokens":72,"modelCalls":6,"apiDurationMs":296,"modelUsage":{"grok-4.6":{"inputTokens":7200,"outputTokens":204,"totalTokens":7404,"cachedReadTokens":6000,"cacheCreationTokens":0,"reasoningTokens":72,"modelCalls":6,"apiDurationMs":296}},"numTurns":6},"elapsed_ms":3645},"_meta":{"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-43","agentTimestampMs":1791011190666}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/prompt_complete","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","stopReason":"end_turn","agentResult":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","title":null,"cwd":"/tmp/grok-fixture/cwork","isWorktree":false,"modelId":"grok-4.6","reasoningEffort":"high","yolo":false,"activity":"idle","resident":true,"lastChangeUnixMs":1791011190667,"origin":{"kind":"local"}}],"removed":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"440d270b-6902-4b29-9ab9-572241521340","result":{"stopReason":"end_turn","_meta":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","requestId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","totalTokens":1234,"modelId":"grok-4.6","inputTokens":1200,"outputTokens":34,"cachedReadTokens":1000,"reasoningTokens":12,"usage":{"inputTokens":7200,"outputTokens":204,"totalTokens":7404,"cachedReadTokens":6000,"cacheCreationTokens":0,"reasoningTokens":72,"modelCalls":6,"apiDurationMs":296,"modelUsage":{"grok-4.6":{"inputTokens":7200,"outputTokens":204,"totalTokens":7404,"cachedReadTokens":6000,"cacheCreationTokens":0,"reasoningTokens":72,"modelCalls":6,"apiDurationMs":296}},"numTurns":6}}}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"14412b6d-c268-436a-aad6-192c6628cb49","method":"session/close","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[],"removed":["01a10095-e65c-7983-a817-6884b010f1ae"]}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"14412b6d-c268-436a-aad6-192c6628cb49","result":{"_meta":{"x.ai/closeOutcome":"closed"}}}} diff --git a/crates/core/src/acp.rs b/crates/core/src/acp.rs index 206b10d48..27da07e01 100644 --- a/crates/core/src/acp.rs +++ b/crates/core/src/acp.rs @@ -42,6 +42,13 @@ fn default_true() -> bool { } impl InstalledAcpAgent { + /// Whether new sessions may use this agent. An agent that duplicates a + /// native integration stays installed, so its existing sessions resume and + /// it can be removed, but new sessions use the native provider. + pub fn offered_for_new_sessions(&self) -> bool { + !agent::HIDDEN_ACP_AGENT_IDS.contains(&self.id.as_str()) + } + /// The whitespace-split launch arguments (mirrors Claude's "Launch arguments"). pub fn extra_args(&self) -> Vec { self.launch_args diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index 797ead890..e6b4ad2a8 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -8,7 +8,7 @@ use std::{ use agent::{ApprovalMode, InteractionMode, OptionSelection, ProviderKind, ResumeCursor}; use serde::{Deserialize, Serialize}; -use crate::settings::{ProjectSort, Settings, acp_color_key, provider_key}; +use crate::settings::{ProjectSort, Settings, acp_color_key}; /// A project groups sessions (threads) that share a working-directory root. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -156,8 +156,8 @@ impl SessionMeta { } /// The key [`Settings::provider_color`] resolves this thread's color from: - /// a user profile id, an ACP agent (`acp:`), or the built-in - /// [`provider_key`]. A user profile is its own provider to the user even + /// a user profile id, an ACP agent (`acp:`), or the built-in profile + /// id. A user profile is its own provider to the user even /// when it drives a built-in protocol, so it never inherits the brand color. pub fn provider_color_key(&self) -> String { if let Some(profile_id) = self @@ -172,7 +172,7 @@ impl SessionMeta { { return acp_color_key(agent_id); } - provider_key(self.provider).to_string() + Settings::builtin_profile_id(self.provider).to_string() } #[cfg(feature = "process")] diff --git a/crates/core/src/provider_colors.rs b/crates/core/src/provider_colors.rs index 60f1eddda..823610c31 100644 --- a/crates/core/src/provider_colors.rs +++ b/crates/core/src/provider_colors.rs @@ -16,12 +16,14 @@ pub fn builtin_provider_color(kind: ProviderKind) -> Option { ProviderKind::Codex => Some(0x8B5CF6), ProviderKind::Pi => Some(0x4D9ABF), ProviderKind::OpenCode => Some(0x22A06B), + ProviderKind::Cursor => Some(0x9CA3AF), + ProviderKind::Grok => Some(0x71717A), ProviderKind::Acp => None, } } -/// Hues for user-added providers (`0xRRGGBB`). They stay clear of the four -/// brand colors above so a custom endpoint never masquerades as a built-in. +/// Hues for user-added providers (`0xRRGGBB`). They stay clear of the brand +/// colors above so a custom endpoint never masquerades as a built-in. pub const PROVIDER_COLOR_PALETTE: [u32; 10] = [ 0xF59E0B, // amber 0x84CC16, // lime diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 2b13ed01a..0cc99b505 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -50,14 +50,17 @@ impl ProjectSort { } } -/// The stable settings-file key for a provider. It keys -/// both `settings.json`'s `providers` map and `secrets.json`. +/// The stable settings-file key for a provider: its entry in `settings.json`'s +/// `providers` map. Profile-keyed data such as `secrets.json` uses +/// [`Settings::builtin_profile_id`]. pub fn provider_key(provider: ProviderKind) -> &'static str { match provider { ProviderKind::Codex => "codex", ProviderKind::ClaudeCode => "claude", ProviderKind::Pi => "pi", ProviderKind::OpenCode => "opencode", + ProviderKind::Cursor => "cursor", + ProviderKind::Grok => "grok", // ACP agents are not one provider but many: their per-agent settings // live in `Settings::acp_agents`, keyed by registry id. This bucket only // ever holds the shared fallbacks (it is never written by the ACP card). @@ -78,6 +81,8 @@ pub fn provider_label(provider: ProviderKind) -> &'static str { ProviderKind::ClaudeCode => "Claude", ProviderKind::Pi => "pi", ProviderKind::OpenCode => "OpenCode", + ProviderKind::Cursor => "Cursor", + ProviderKind::Grok => "Grok", ProviderKind::Acp => "ACP", } } @@ -115,8 +120,8 @@ pub struct ProviderSettings { /// Override for the CLI binary (`None` = resolve from PATH). #[serde(default, skip_serializing_if = "Option::is_none")] pub binary_path: Option, - /// Claude: `HOME`; Codex: `CODEX_HOME`; pi: `PI_CODING_AGENT_DIR`. - /// OpenCode ignores this field because it has no single-home override. + /// The provider's home variable ([`agent::LaunchEnv::home`]). OpenCode + /// ignores this field because it has no single-home override. #[serde(default, skip_serializing_if = "Option::is_none")] pub home_path: Option, /// Native-provider CLI arguments appended on session start (ignored for Codex). @@ -221,8 +226,7 @@ impl ProviderSettings { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProviderProfile { /// The protocol this profile drives. Determines which native adapter - /// (`claude` / `codex` / `pi` / `opencode`) spawns it and how its native - /// protocol is normalized. + /// spawns it and how its native protocol is normalized. pub kind: ProviderKind, /// The card configuration (env, binary, home, models, display name, …). /// Flattened so a profile's JSON is a superset of a provider card's. @@ -1270,11 +1274,23 @@ impl Settings { .or_default() } - /// The built-in profile id for a native protocol (its [`provider_key`]). - /// This is the id a session carries when it uses the default, non-custom - /// configuration for its kind. + /// The built-in profile id for a native protocol. This is the id a session + /// carries when it uses the default, non-custom configuration for its kind. + /// + /// Built-in ids resolve before user profiles, whose ids are slugs of ASCII + /// alphanumerics and hyphens. Providers added after user profiles existed + /// therefore take a `native:` id no slug can produce, so a user profile + /// that already has the provider's name keeps resolving to itself. pub fn builtin_profile_id(kind: ProviderKind) -> &'static str { - provider_key(kind) + match kind { + ProviderKind::Cursor => "native:cursor", + ProviderKind::Grok => "native:grok", + ProviderKind::ClaudeCode + | ProviderKind::Codex + | ProviderKind::Pi + | ProviderKind::OpenCode + | ProviderKind::Acp => provider_key(kind), + } } /// Whether `id` names a built-in provider profile. @@ -1285,14 +1301,10 @@ impl Settings { /// The protocol kind of a built-in profile id, if it is one. Used to route a /// mutation of a built-in profile back to its `providers` card. pub fn builtin_kind_from_id(id: &str) -> Option { - match id { - "claude" => Some(ProviderKind::ClaudeCode), - "codex" => Some(ProviderKind::Codex), - "pi" => Some(ProviderKind::Pi), - "opencode" => Some(ProviderKind::OpenCode), - "acp" => Some(ProviderKind::Acp), - _ => None, - } + ProviderKind::NATIVE + .into_iter() + .chain([ProviderKind::Acp]) + .find(|kind| Self::builtin_profile_id(*kind) == id) } /// Resolve a profile id to its protocol kind and effective card settings. @@ -1318,7 +1330,7 @@ impl Settings { /// picker iterates. pub fn profiles_for_kind(&self, kind: ProviderKind) -> Vec { let mut out = vec![ResolvedProfile { - id: provider_key(kind).to_string(), + id: Self::builtin_profile_id(kind).to_string(), kind, settings: self.provider(kind), }]; @@ -2073,6 +2085,36 @@ mod tests { assert_eq!(settings.allocate_profile_id("Klaude Kode"), "klaude-kode-2"); } + #[test] + fn a_user_profile_named_after_a_new_native_provider_keeps_its_identity() { + // Saved before Cursor was native: a Claude profile the user named "Cursor". + let settings: Settings = serde_json::from_str( + r##"{"profiles":{"cursor":{"kind":"claude_code","display_name":"Cursor","accent_color":"#123456"}}}"##, + ) + .unwrap(); + assert_eq!( + settings.resolved_profile("cursor").unwrap().kind, + ProviderKind::ClaudeCode + ); + let [builtin] = settings + .profiles_for_kind(ProviderKind::Cursor) + .try_into() + .unwrap(); + assert_eq!(builtin.kind, ProviderKind::Cursor); + assert_eq!( + settings.resolved_profile(&builtin.id).unwrap().kind, + ProviderKind::Cursor + ); + assert!(!Settings::is_builtin_profile_id( + &settings.allocate_profile_id(&builtin.id) + )); + let thread = crate::project::SessionMeta::new(ProviderKind::Cursor, "/x".into(), None); + assert_ne!( + settings.provider_color(&thread.provider_color_key()), + 0x123456 + ); + } + /// A build that predates a field must not destroy it: unknown keys survive a /// load → save round trip. (We hit this for real: an older binary dropped /// `acp_agents` and the next save wiped the installed agents.) diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 3deab44d3..8df091270 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -38,8 +38,10 @@ pub use wire::{ // authentication into the transport, so hello carries no token; version 6 // sends index and history changes instead of whole replacements and // compresses the native transport; version 7 carries the running turn and -// the requests it waits on in the session status. -pub const PROTOCOL_VERSION: u32 = 7; +// the requests it waits on in the session status; version 8 adds the Cursor +// and Grok provider kinds, which an older peer cannot decode because +// `ProviderKind` has no unknown fallback. +pub const PROTOCOL_VERSION: u32 = 8; #[cfg(test)] mod tests; diff --git a/crates/runtime/src/app/command_validation.rs b/crates/runtime/src/app/command_validation.rs index 3cbb2afbb..2e35b6bcf 100644 --- a/crates/runtime/src/app/command_validation.rs +++ b/crates/runtime/src/app/command_validation.rs @@ -83,6 +83,21 @@ impl AppState { { return Err(error("unknown_acp_agent", "This agent is not installed.")); } + Command::SetActiveAcpAgent { session_id, id } + if self + .settings + .acp_agents + .get(id) + .is_some_and(|agent| !agent.offered_for_new_sessions()) + && self.resident(session_id).is_none_or(|session| { + session.meta.acp_agent_id.as_deref() != Some(id.as_str()) + }) => + { + return Err(error( + "native_acp_agent", + "This agent duplicates a native provider; use that provider for new sessions.", + )); + } Command::MergeWorktree { session_id } => { return match self.find_meta(session_id) { None => Err(error( diff --git a/crates/runtime/src/app/events.rs b/crates/runtime/src/app/events.rs index 31abff373..5bf72eec9 100644 --- a/crates/runtime/src/app/events.rs +++ b/crates/runtime/src/app/events.rs @@ -165,7 +165,12 @@ impl AppState { apply(resident); resident.meta.clone() }); - if let Some(meta) = meta.filter(|meta| meta.acp_agent_id.is_some()) { + if let Some(meta) = meta.filter(|meta| { + matches!( + meta.provider.caps().option_descriptors, + OptionDescriptors::Wire + ) + }) { self.persist_meta(&meta, cx); } return; diff --git a/crates/runtime/src/app/mod.rs b/crates/runtime/src/app/mod.rs index a689a4d06..2c0c06852 100644 --- a/crates/runtime/src/app/mod.rs +++ b/crates/runtime/src/app/mod.rs @@ -93,12 +93,6 @@ const RESIDENT_IDLE_GRACE: Duration = Duration::from_secs(60 * 60); /// the primary bound, while this comfortably preserves typical orchestrate /// fleets whose idle children may be re-messaged. const MAX_IDLE_RESIDENTS: usize = 16; -const NATIVE_PROVIDER_KINDS: [ProviderKind; 4] = [ - ProviderKind::ClaudeCode, - ProviderKind::Codex, - ProviderKind::Pi, - ProviderKind::OpenCode, -]; type ProviderLaunchFuture = Pin> + Send + 'static>>; @@ -479,7 +473,7 @@ impl AppState { // works offline; a background refresh (see `refresh_model_catalogs`) // updates it once the providers respond. let mut model_catalogs = HashMap::new(); - for provider in NATIVE_PROVIDER_KINDS { + for provider in ProviderKind::NATIVE { let cached = store.load_models(provider); if !cached.is_empty() { model_catalogs.insert(provider, cached); diff --git a/crates/runtime/src/app/orchestrate.rs b/crates/runtime/src/app/orchestrate.rs index d514de101..bdd3a179f 100644 --- a/crates/runtime/src/app/orchestrate.rs +++ b/crates/runtime/src/app/orchestrate.rs @@ -1435,6 +1435,8 @@ fn resolve_orchestrate_profiles( "codex" => ProviderKind::Codex, "pi" => ProviderKind::Pi, "opencode" | "open_code" | "open-code" => ProviderKind::OpenCode, + "cursor" => ProviderKind::Cursor, + "grok" => ProviderKind::Grok, "acp" => { return Err( "ACP child dispatch is not available yet; configure a native-provider child model" @@ -1580,6 +1582,8 @@ pub(super) fn provider_name(provider: ProviderKind) -> &'static str { ProviderKind::ClaudeCode => "claude", ProviderKind::Pi => "pi", ProviderKind::OpenCode => "opencode", + ProviderKind::Cursor => "cursor", + ProviderKind::Grok => "grok", ProviderKind::Acp => "acp", } } diff --git a/crates/runtime/src/app/providers.rs b/crates/runtime/src/app/providers.rs index a5e48e660..f65d7f8d8 100644 --- a/crates/runtime/src/app/providers.rs +++ b/crates/runtime/src/app/providers.rs @@ -159,7 +159,7 @@ impl AppState { /// at app start and after a binary-path change). Results update /// `model_catalogs` and are persisted so the next launch is instant. pub fn refresh_model_catalogs(&mut self, cx: &mut HostCx) { - for provider in NATIVE_PROVIDER_KINDS { + for provider in ProviderKind::NATIVE { let binary = self.settings.provider(provider).binary_path; let settings = self.settings.clone(); let settings_store = self.settings_store.clone(); @@ -229,8 +229,8 @@ impl AppState { } // A *profile* is a named configuration on top of a protocol `ProviderKind`. - // The built-in native-provider cards are profiles too (with stable ids such - // as "claude", "codex", "pi", and "opencode"). + // The built-in native-provider cards are profiles too, with the stable ids + // of `Settings::builtin_profile_id`. // The model catalog and update-check version stay keyed by kind; status // probes and card config (env, binary, home, accent, custom/hidden models) // are profile-specific, as are secrets. @@ -238,7 +238,7 @@ impl AppState { /// Every selectable native profile, grouped by kind. ACP is handled /// separately through the installed-agent list. pub(super) fn all_profiles(&self) -> Vec { - NATIVE_PROVIDER_KINDS + ProviderKind::NATIVE .iter() .flat_map(|kind| self.settings.profiles_for_kind(*kind)) .collect() @@ -530,7 +530,7 @@ impl AppState { /// Check every provider and the running tcode build in the background, /// storing results and toasting once for each newly available update. pub fn check_provider_versions(&mut self, cx: &mut HostCx) { - for provider in NATIVE_PROVIDER_KINDS { + for provider in ProviderKind::NATIVE { let binary = self.resolve_provider_binary(provider); let status = self .providers @@ -803,24 +803,19 @@ pub(super) fn provider_secret_names( settings: &Settings, settings_store: &SettingsStore, ) -> HashMap> { - [ - ProviderKind::Codex, - ProviderKind::ClaudeCode, - ProviderKind::Pi, - ProviderKind::OpenCode, - ] - .into_iter() - .flat_map(|kind| settings.profiles_for_kind(kind)) - .map(|profile| { - let id = profile.id; - let names = launch_env_for_profile(settings, &id, settings_store.profile_secrets(&id)) - .env - .into_iter() - .map(|(name, _)| name) - .collect(); - (id, names) - }) - .collect() + ProviderKind::NATIVE + .into_iter() + .flat_map(|kind| settings.profiles_for_kind(kind)) + .map(|profile| { + let id = profile.id; + let names = launch_env_for_profile(settings, &id, settings_store.profile_secrets(&id)) + .env + .into_iter() + .map(|(name, _)| name) + .collect(); + (id, names) + }) + .collect() } pub(super) fn session_launch_env( @@ -833,7 +828,9 @@ pub(super) fn session_launch_env( ProviderKind::Codex | ProviderKind::ClaudeCode | ProviderKind::Pi - | ProviderKind::OpenCode => { + | ProviderKind::OpenCode + | ProviderKind::Cursor + | ProviderKind::Grok => { let profile_id = meta .profile_id .clone() @@ -926,7 +923,10 @@ pub(super) fn session_options( // an ACP agent carries its own from the installed-agent card. extra_args: if meta.provider.caps().launch_args { match meta.provider { - ProviderKind::ClaudeCode | ProviderKind::OpenCode => provider_settings.extra_args(), + ProviderKind::ClaudeCode + | ProviderKind::OpenCode + | ProviderKind::Cursor + | ProviderKind::Grok => provider_settings.extra_args(), ProviderKind::Pi => { let mut extra_args = provider_settings.extra_args(); if provider_settings.pi.trust_project_extensions { diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index 7fa3da62f..eb4355575 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -595,7 +595,7 @@ impl AppState { pub fn update_settings(&mut self, settings: Settings, cx: &mut HostCx) { self.enqueue_settings(&settings, cx); - if NATIVE_PROVIDER_KINDS + if ProviderKind::NATIVE .iter() .any(|&provider| self.settings.provider(provider) != settings.provider(provider)) { diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index 3e6a8bcf7..db468a536 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -1811,6 +1811,70 @@ fn title_regeneration_context_keeps_the_original_goal_and_recent_messages() { ); } +#[test] +fn installed_acp_duplicates_of_native_providers_only_serve_their_existing_sessions() { + let cx = &mut TestAppContext::default(); + let test_store = TestStore::new("tcode-native-acp-duplicates"); + let state = cx.new_entity(TestClientState::new((*test_store).clone())); + let installed = |id: &str| InstalledAgent { + id: id.into(), + name: id.into(), + version: String::new(), + icon: None, + launch: agent::AcpLaunch::Custom { + command: id.into(), + args: Vec::new(), + env: Vec::new(), + }, + enabled: true, + env: Vec::new(), + launch_args: None, + }; + let draft = state.update(cx, |state, cx| { + for id in ["cursor", "grok-build", "gemini"] { + state.settings.acp_agents.insert(id.into(), installed(id)); + } + state.start_draft("project".into(), PathBuf::from("/tmp/project"), cx); + state.selected.clone().unwrap() + }); + for (request, id, selected) in [ + (1, "cursor", None), + (2, "grok-build", None), + (3, "gemini", Some("gemini")), + ] { + state.dispatch_command( + cx, + request, + Command::SetActiveAcpAgent { + session_id: draft.clone(), + id: id.into(), + }, + ); + state.read(|state| { + assert_eq!( + state.resident(&draft).unwrap().meta.acp_agent_id.as_deref(), + selected, + "{id}" + ); + }); + } + + let mut existing = SessionMeta::new(ProviderKind::Acp, PathBuf::from("/tmp/project"), None); + existing.acp_agent_id = Some("cursor".into()); + state.read(|state| { + let opts = session_options( + &existing, + &state.settings, + LaunchEnv::default(), + None, + None, + None, + None, + ); + assert_eq!(opts.acp.map(|agent| agent.id).as_deref(), Some("cursor")); + }); +} + #[test] fn marketplace_items_are_runtime_owned_views() { let test_store = TestStore::new("tcode-marketplace-view-test"); diff --git a/crates/services/src/provider_probe.rs b/crates/services/src/provider_probe.rs index f0c3b0010..52f2bc0fa 100644 --- a/crates/services/src/provider_probe.rs +++ b/crates/services/src/provider_probe.rs @@ -16,6 +16,8 @@ pub fn default_program(provider: ProviderKind) -> String { ProviderKind::ClaudeCode => "claude".into(), ProviderKind::Pi => "pi".into(), ProviderKind::OpenCode => "opencode".into(), + ProviderKind::Cursor => "cursor-agent".into(), + ProviderKind::Grok => "grok".into(), // ACP agents carry their own registry launch recipe. ProviderKind::Acp => String::new(), } @@ -122,13 +124,73 @@ pub async fn probe_provider( let json = path.and_then(|path| std::fs::read_to_string(path).ok()); json.as_deref().and_then(parse_aggregator_auth) } - // ACP authentication is surfaced by its session protocol. + ProviderKind::Cursor => run_capture_env(&program, &["status", "--format", "json"], &env) + .await + .as_deref() + .and_then(|json| parse_cursor_auth(json, cursor_key_configured(&env))), + ProviderKind::Grok => run_capture_env(&program, &["models"], &env) + .await + .as_deref() + .and_then(parse_grok_models_auth), + // Authentication over ACP is surfaced by the session protocol. ProviderKind::Acp => None, }; finalize_probe(checked_at, version, auth) } +/// `cursor-agent status --format json` reports only the stored login: it says +/// `unauthenticated` while CURSOR_API_KEY or CURSOR_AUTH_TOKEN signs the CLI +/// in, so it counts as signed out only when neither is set. +fn parse_cursor_auth(json: &str, key_configured: bool) -> Option { + let status: serde_json::Value = serde_json::from_str(json).ok()?; + if status.get("isAuthenticated") == Some(&serde_json::Value::Bool(true)) { + return Some(ProviderAuth { + status: AuthStatus::Authenticated, + label: None, + email: status + .pointer("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/userInfo/email") + .and_then(serde_json::Value::as_str) + .filter(|email| !email.is_empty()) + .map(str::to_string), + }); + } + (status.get("status").and_then(serde_json::Value::as_str) == Some("unauthenticated") + && !key_configured) + .then_some(ProviderAuth { + status: AuthStatus::Unauthenticated, + label: None, + email: None, + }) +} + +/// Whether Cursor's child sees an API key or auth token, from the profile's +/// environment or the one it inherits. +fn cursor_key_configured(env: &[(String, String)]) -> bool { + ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"].iter().any(|key| { + match env.iter().rev().find(|(name, _)| name == key) { + Some((_, value)) => !value.is_empty(), + None => std::env::var_os(key).is_some_and(|value| !value.is_empty()), + } + }) +} + +/// The first line of `grok models`. Only the states observed from Grok 1.0.46 +/// are recognized; the API-key line reports a configured key, which Grok does +/// not validate there. +fn parse_grok_models_auth(output: &str) -> Option { + let (status, label) = match output.lines().next()?.trim() { + "You are not authenticated." => (AuthStatus::Unauthenticated, None), + "You are using XAI_API_KEY." => (AuthStatus::Authenticated, Some("xAI API Key")), + _ => return None, + }; + Some(ProviderAuth { + status, + label: label.map(str::to_string), + email: None, + }) +} + fn finalize_probe( checked_at: Option, version: Option, @@ -180,6 +242,49 @@ mod tests { assert_eq!(result.diagnostic, Some(ProviderProbeDiagnostic::MissingCli)); } + /// `cursor-agent status --format json` from the agent crate's stand-in, + /// which prints the fixture it is pointed at. + #[cfg(unix)] + #[test] + fn cursor_is_signed_out_only_without_an_api_key_or_auth_token() { + let fixtures = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../agent/tests/fixtures/cursor"); + let probe = |status: &str, api_key: &str, auth_token: &str| { + let env = vec![ + ( + "TCODE_STAND_IN_STATUS".to_string(), + fixtures.join(status).display().to_string(), + ), + ("CURSOR_API_KEY".to_string(), api_key.to_string()), + ("CURSOR_AUTH_TOKEN".to_string(), auth_token.to_string()), + ]; + smol::block_on(probe_provider( + ProviderKind::Cursor, + Some(fixtures.join("cursor-agent")), + LaunchEnv { env, home: None }, + )) + }; + + let signed_out = probe("status_signed_out.json", "", ""); + assert_eq!( + signed_out.diagnostic, + Some(ProviderProbeDiagnostic::Unauthenticated) + ); + for (api_key, auth_token) in [("key", ""), ("", "token")] { + assert_eq!( + probe("status_signed_out.json", api_key, auth_token).diagnostic, + Some(ProviderProbeDiagnostic::IndeterminateAuth), + "a key or token signs Cursor in without a stored login" + ); + } + + let signed_in = probe("status_signed_in.json", "", ""); + assert_eq!(signed_in.status, Some(ProviderStatusKind::Ready)); + let auth = signed_in.auth.unwrap(); + assert_eq!(auth.status, AuthStatus::Authenticated); + assert_eq!(auth.email.as_deref(), Some("dev@example.com")); + } + #[test] fn auth_outcomes_are_semantic_and_unlocalized() { let authenticated = finalize_probe( diff --git a/crates/services/src/provider_usage.rs b/crates/services/src/provider_usage.rs index de9774049..b4d356eee 100644 --- a/crates/services/src/provider_usage.rs +++ b/crates/services/src/provider_usage.rs @@ -177,7 +177,11 @@ pub async fn fetch_provider_usage( ProviderKind::ClaudeCode => agent::claude::read_usage(binary, launch_env) .await .map(|value| parse_claude_usage(&value, fetched_at)), - ProviderKind::Pi | ProviderKind::OpenCode | ProviderKind::Acp => return None, + ProviderKind::Pi + | ProviderKind::OpenCode + | ProviderKind::Cursor + | ProviderKind::Grok + | ProviderKind::Acp => return None, }; Some(result.unwrap_or_else(|error| error_usage(fetched_at, error.to_string()))) } diff --git a/crates/services/src/settings.rs b/crates/services/src/settings.rs index d9cfa5d27..6246889ed 100644 --- a/crates/services/src/settings.rs +++ b/crates/services/src/settings.rs @@ -8,8 +8,6 @@ use std::path::PathBuf; use agent::ProviderKind; use tcode_core::settings::Settings; #[cfg(test)] -use tcode_core::settings::provider_key; -#[cfg(test)] use tcode_core::settings::{EnvVar, ThemeMode, TraverseSetting}; #[derive(Debug, Clone)] @@ -50,9 +48,8 @@ impl SettingsStore { .unwrap_or_default() } - /// The sensitive env values for one profile id. Built-in profiles use their - /// [`provider_key`] as id, so this also covers them; user-created profiles - /// are keyed by their slug id. + /// The sensitive env values for one profile id: a built-in profile's + /// [`Settings::builtin_profile_id`] or a user profile's slug. pub fn profile_secrets(&self, profile_id: &str) -> BTreeMap { self.load_secrets().remove(profile_id).unwrap_or_default() } @@ -187,7 +184,7 @@ mod tests { store.save(&settings).unwrap(); store .set_profile_secret( - provider_key(ProviderKind::ClaudeCode), + Settings::builtin_profile_id(ProviderKind::ClaudeCode), "ANTHROPIC_API_KEY", Some("sk-live"), ) @@ -224,20 +221,20 @@ mod tests { // Clearing removes the entry (and the now-empty provider bucket). store .set_profile_secret( - provider_key(ProviderKind::ClaudeCode), + Settings::builtin_profile_id(ProviderKind::ClaudeCode), "ANTHROPIC_API_KEY", None, ) .unwrap(); assert!(store.profile_secrets("claude").is_empty()); // A user profile "klaude-kode" stores its own key, isolated from the - // built-in "claude" profile (which shares the provider_key id). + // built-in "claude" profile. store .set_profile_secret("klaude-kode", "ANTHROPIC_API_KEY", Some("sk-kimi-xyz")) .unwrap(); store .set_profile_secret( - provider_key(ProviderKind::ClaudeCode), + Settings::builtin_profile_id(ProviderKind::ClaudeCode), "ANTHROPIC_API_KEY", Some("sk-official"), ) @@ -250,7 +247,7 @@ mod tests { .map(String::as_str), Some("sk-kimi-xyz") ); - // Built-in profile id == provider_key. + // The built-in Claude profile keeps its `claude` bucket. assert_eq!( store .profile_secrets("claude") diff --git a/crates/services/src/store.rs b/crates/services/src/store.rs index ec028c2e0..3e9498d14 100644 --- a/crates/services/src/store.rs +++ b/crates/services/src/store.rs @@ -100,6 +100,8 @@ impl SessionStore { ProviderKind::ClaudeCode => "claude", ProviderKind::Pi => "pi", ProviderKind::OpenCode => "opencode", + ProviderKind::Cursor => "cursor", + ProviderKind::Grok => "grok", // ACP agents publish their models over the wire at session start // (`AgentEvent::ProviderOptions`), so there is no catalog to cache. ProviderKind::Acp => "acp", @@ -113,6 +115,8 @@ impl SessionStore { ProviderKind::ClaudeCode => "claude".to_string(), ProviderKind::Pi => "pi".to_string(), ProviderKind::OpenCode => "opencode".to_string(), + ProviderKind::Cursor => "cursor".to_string(), + ProviderKind::Grok => "grok".to_string(), ProviderKind::Acp => { let id = acp_agent_id?; // Registry ids are external input and may contain path separators. diff --git a/crates/services/src/version_check/provider_updates.rs b/crates/services/src/version_check/provider_updates.rs index 9be38b97b..5aac156e2 100644 --- a/crates/services/src/version_check/provider_updates.rs +++ b/crates/services/src/version_check/provider_updates.rs @@ -63,7 +63,9 @@ pub fn npm_package(provider: ProviderKind) -> &'static str { ProviderKind::Codex => "@openai/codex", ProviderKind::Pi => "@earendil-works/pi-coding-agent", ProviderKind::OpenCode => "opencode-ai", - ProviderKind::Acp => "", + ProviderKind::Grok => "@xai-official/grok", + // Cursor's CLI is not published to npm. + ProviderKind::Cursor | ProviderKind::Acp => "", } } diff --git a/crates/services/src/version_check/provider_updates/installation.rs b/crates/services/src/version_check/provider_updates/installation.rs index c29a5327b..cad6a4da2 100644 --- a/crates/services/src/version_check/provider_updates/installation.rs +++ b/crates/services/src/version_check/provider_updates/installation.rs @@ -495,6 +495,8 @@ fn binary_name(provider: ProviderKind) -> &'static str { ProviderKind::Codex => "codex", ProviderKind::Pi => "pi", ProviderKind::OpenCode => "opencode", + ProviderKind::Cursor => "cursor-agent", + ProviderKind::Grok => "grok", ProviderKind::Acp => "", } } @@ -814,7 +816,7 @@ fn provider_brew_package(provider: ProviderKind, name: &str) -> bool { ProviderKind::Codex => matches!(name, "codex" | "codex@alpha"), ProviderKind::Pi => name == "pi-coding-agent", ProviderKind::OpenCode => matches!(name, "opencode" | "opencode-v2"), - ProviderKind::Acp => false, + ProviderKind::Cursor | ProviderKind::Grok | ProviderKind::Acp => false, } } @@ -1086,7 +1088,8 @@ async fn javascript_windows(context: &Context<'_, P>) -> Option &["opencode-ai", "@opencode/cli"], - ProviderKind::Acp => return None, + ProviderKind::Grok => &["@xai-official/grok"], + ProviderKind::Cursor | ProviderKind::Acp => return None, }; for (manager, source, args, bin_args) in [ ( diff --git a/crates/ui/src/assets.rs b/crates/ui/src/assets.rs index 94d7b3446..4e1fc97c2 100644 --- a/crates/ui/src/assets.rs +++ b/crates/ui/src/assets.rs @@ -81,6 +81,14 @@ const EXTRA_ICONS: &[(&str, &[u8])] = &[ "icons/opencode.svg", include_bytes!("../../../assets/icons/opencode.svg"), ), + ( + "icons/cursor.svg", + include_bytes!("../../../assets/icons/cursor.svg"), + ), + ( + "icons/grok.svg", + include_bytes!("../../../assets/icons/grok.svg"), + ), ( "icons/wrench.svg", include_bytes!("../../../assets/icons/wrench.svg"), diff --git a/crates/ui/src/composer/components/pickers.rs b/crates/ui/src/composer/components/pickers.rs index 587a21d01..cf669d173 100644 --- a/crates/ui/src/composer/components/pickers.rs +++ b/crates/ui/src/composer/components/pickers.rs @@ -94,7 +94,7 @@ impl Composer { let query = self.model_search.read(cx).value().to_lowercase(); let fav_profiles: Vec<(String, ProviderKind)> = { let profiles = store.enabled_profiles(); - PICKER_PROVIDER_KINDS + ProviderKind::NATIVE .into_iter() .flat_map(|kind| { profiles @@ -195,7 +195,7 @@ impl Composer { let acp_rail_agents: Vec<(String, String)> = store .settings_installed_acp_agents() .into_iter() - .filter(|agent| agent.enabled) + .filter(|agent| agent.enabled && agent.offered_for_new_sessions()) .map(|agent| (agent.id.clone(), agent.name.clone())) .collect(); @@ -723,7 +723,7 @@ fn render_model_pane( let profile_ids: Vec = { let store = store_entity.read(cx); let profiles = store.enabled_profiles(); - PICKER_PROVIDER_KINDS + ProviderKind::NATIVE .into_iter() .flat_map(|kind| { profiles diff --git a/crates/ui/src/composer/mod.rs b/crates/ui/src/composer/mod.rs index 886296ec5..5407a0bdb 100644 --- a/crates/ui/src/composer/mod.rs +++ b/crates/ui/src/composer/mod.rs @@ -81,12 +81,6 @@ use crate::usage::{METER_BLUE, METER_RED}; /// File mentions are potentially unbounded; command and skill feeds are not /// capped and instead use the trigger menu's scrolling viewport. const FILE_MENU_ROW_CAP: usize = 50; -const PICKER_PROVIDER_KINDS: [ProviderKind; 4] = [ - ProviderKind::ClaudeCode, - ProviderKind::Codex, - ProviderKind::Pi, - ProviderKind::OpenCode, -]; /// Stop-button red-orange. const STOP_TINT: u32 = 0xF4562E; diff --git a/crates/ui/src/provider_card.rs b/crates/ui/src/provider_card.rs index aac2b562a..0ce97fb51 100644 --- a/crates/ui/src/provider_card.rs +++ b/crates/ui/src/provider_card.rs @@ -431,6 +431,8 @@ pub fn provider_glyph(provider: ProviderKind) -> Icon { ProviderKind::Codex => ("icons/openai.svg", None), ProviderKind::Pi => ("icons/pi.svg", None), ProviderKind::OpenCode => ("icons/opencode.svg", None), + ProviderKind::Cursor => ("icons/cursor.svg", None), + ProviderKind::Grok => ("icons/grok.svg", None), ProviderKind::Acp => return Icon::empty(), }; Icon::empty() diff --git a/crates/ui/src/provider_dialog.rs b/crates/ui/src/provider_dialog.rs index 1c7ee4f24..8cd5125a5 100644 --- a/crates/ui/src/provider_dialog.rs +++ b/crates/ui/src/provider_dialog.rs @@ -105,7 +105,7 @@ impl ProviderDialog { window, cx, ); - let &[ + let [ binary_name, home_placeholder, _, @@ -538,7 +538,7 @@ impl ProviderDialog { ), ]; if provider.caps().home_path { - let &[_, _, home_label, home_help, _, _] = provider_copy(provider); + let [_, _, home_label, home_help, _, _] = provider_copy(provider); blocks.push( self.field_block( crate::tr!(home_label).into_owned().into(), @@ -549,6 +549,8 @@ impl ProviderDialog { cx, ), ); + } + if provider.caps().downgrade_approval_without_native_approvals { blocks.push( self.field_block( crate::tr!("providers.pi_native_approvals") @@ -974,19 +976,16 @@ pub fn render_footer( type ProviderCopy = [&'static str; 6]; #[rustfmt::skip] -const PROVIDER_COPY: [(ProviderKind, ProviderCopy); 5] = [ - (ProviderKind::Codex, ["codex", "~/.codex", "providers.codex_home", "providers.codex_home_help", "", "gpt-6.7-codex-ultra-preview"]), - (ProviderKind::ClaudeCode, ["claude", "~", "providers.claude_home", "providers.claude_home_help", "e.g. --chrome", "claude-sonnet-5"]), - (ProviderKind::Pi, ["pi", "~/.pi/agent", "providers.pi_home", "providers.pi_home_help", "e.g. --provider openai-codex", "openai-codex/gpt-5.5"]), - (ProviderKind::OpenCode, ["opencode", "", "providers.home", "providers.home_help", "e.g. --print-logs", "openai/gpt-5.1-codex"]), - (ProviderKind::Acp, ["", "", "providers.home", "providers.home_help", "", ""]), -]; - -fn provider_copy(provider: ProviderKind) -> &'static ProviderCopy { - PROVIDER_COPY - .iter() - .find_map(|(kind, copy)| (*kind == provider).then_some(copy)) - .expect("every provider has dialog copy") +fn provider_copy(provider: ProviderKind) -> ProviderCopy { + match provider { + ProviderKind::Codex => ["codex", "~/.codex", "providers.codex_home", "providers.codex_home_help", "", "gpt-6.7-codex-ultra-preview"], + ProviderKind::ClaudeCode => ["claude", "~", "providers.claude_home", "providers.claude_home_help", "e.g. --chrome", "claude-sonnet-5"], + ProviderKind::Pi => ["pi", "~/.pi/agent", "providers.pi_home", "providers.pi_home_help", "e.g. --provider openai-codex", "openai-codex/gpt-5.5"], + ProviderKind::OpenCode => ["opencode", "", "providers.home", "providers.home_help", "e.g. --print-logs", "openai/gpt-5.1-codex"], + ProviderKind::Cursor => ["cursor-agent", "~/.cursor", "providers.cursor_home", "providers.cursor_home_help", "e.g. --sandbox enabled", ""], + ProviderKind::Grok => ["grok", "~/.grok", "providers.grok_home", "providers.grok_home_help", "e.g. --no-leader", ""], + ProviderKind::Acp => ["", "", "providers.home", "providers.home_help", "", ""], + } } fn path_string(path: &Option) -> String { diff --git a/crates/ui/src/provider_status.rs b/crates/ui/src/provider_status.rs index 871d1b36b..2bce071f9 100644 --- a/crates/ui/src/provider_status.rs +++ b/crates/ui/src/provider_status.rs @@ -178,6 +178,8 @@ pub fn probe_diagnostic_message( ProviderKind::ClaudeCode => crate::tr!("providers.probe.claude_missing").into_owned(), ProviderKind::Pi => crate::tr!("providers.probe.pi_missing").into_owned(), ProviderKind::OpenCode => crate::tr!("providers.probe.opencode_missing").into_owned(), + ProviderKind::Cursor => crate::tr!("providers.probe.cursor_missing").into_owned(), + ProviderKind::Grok => crate::tr!("providers.probe.grok_missing").into_owned(), ProviderKind::Acp => String::new(), }, ProviderProbeDiagnostic::FailedCli => crate::tr!( @@ -194,6 +196,8 @@ pub fn probe_diagnostic_message( ProviderKind::OpenCode => { crate::tr!("providers.probe.opencode_signed_out").into_owned() } + ProviderKind::Cursor => crate::tr!("providers.probe.cursor_signed_out").into_owned(), + ProviderKind::Grok => crate::tr!("providers.probe.grok_signed_out").into_owned(), ProviderKind::Acp => String::new(), }, ProviderProbeDiagnostic::IndeterminateAuth => crate::tr!( diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 23bf88c45..2bdf5d4c2 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -1254,16 +1254,10 @@ impl WorkspaceStore { } pub fn all_provider_profiles(&self) -> Vec { - let mut profiles = Vec::new(); - for kind in [ - agent::ProviderKind::Codex, - agent::ProviderKind::ClaudeCode, - agent::ProviderKind::Pi, - agent::ProviderKind::OpenCode, - ] { - profiles.extend(self.settings_replica.profiles_for_kind(kind)); - } - profiles + agent::ProviderKind::NATIVE + .into_iter() + .flat_map(|kind| self.settings_replica.profiles_for_kind(kind)) + .collect() } pub fn enabled_profiles(&self) -> Vec { diff --git a/locales/en.yml b/locales/en.yml index 4401db28c..22e417633 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -468,12 +468,16 @@ providers: claude_missing: "Claude Agent CLI (`claude`) is not installed or not on PATH." pi_missing: "pi CLI (`pi`) is not installed or not on PATH." opencode_missing: "OpenCode CLI (`opencode`) is not installed or not on PATH." + cursor_missing: "Cursor CLI (`cursor-agent`) is not installed or not on PATH." + grok_missing: "Grok CLI (`grok`) is not installed or not on PATH." failed_run: "%{provider} CLI is installed but failed to run." indeterminate_auth: "Could not verify %{provider} authentication status." codex_signed_out: "Codex CLI is not authenticated. Run `codex login` and try again." claude_signed_out: "Claude CLI is not authenticated. Run `claude auth login` and try again." pi_signed_out: "pi has no authenticated model provider. Configure one in pi and try again." opencode_signed_out: "OpenCode has no authenticated model provider. Run `opencode auth login` and try again." + cursor_signed_out: "Cursor CLI is not signed in. Run `cursor-agent login` and try again." + grok_signed_out: "Grok is not signed in. Set XAI_API_KEY or run `grok login`, then try again." # Update popover (T3 §3) update_aria: "Update available — view details" update_title: "Update available" @@ -537,6 +541,10 @@ providers: claude_home_help: "Custom HOME used when running this Claude instance. Keeps .claude.json and .claude separate." pi_home: "PI_CODING_AGENT_DIR path" pi_home_help: "Custom pi agent directory. Keeps sessions, auth, models, prompts, and extensions separate." + cursor_home: "CURSOR_CONFIG_DIR path" + cursor_home_help: "Custom Cursor CLI configuration directory." + grok_home: "GROK_HOME path" + grok_home_help: "Custom Grok home directory. Keeps sessions, configuration, and credentials separate." home: "Provider home path" home_help: "Custom home or data directory used by this provider instance." launch_args: "Launch arguments" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 5c7cc8f2a..2354e0a1a 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -465,12 +465,16 @@ providers: claude_missing: "未安装 Claude Agent CLI(`claude`),或其不在 PATH 中。" pi_missing: "未安装 pi CLI(`pi`),或其不在 PATH 中。" opencode_missing: "未安装 OpenCode CLI(`opencode`),或其不在 PATH 中。" + cursor_missing: "未安装 Cursor CLI(`cursor-agent`),或其不在 PATH 中。" + grok_missing: "未安装 Grok CLI(`grok`),或其不在 PATH 中。" failed_run: "%{provider} CLI 已安装,但运行失败。" indeterminate_auth: "无法验证 %{provider} 的认证状态。" codex_signed_out: "Codex CLI 未认证。请运行 `codex login` 后重试。" claude_signed_out: "Claude CLI 未认证。请运行 `claude auth login` 后重试。" pi_signed_out: "pi 尚未配置已认证的模型提供方。请在 pi 中完成配置后重试。" opencode_signed_out: "OpenCode 尚未配置已认证的模型提供方。请运行 `opencode auth login` 后重试。" + cursor_signed_out: "Cursor CLI 尚未登录。请运行 `cursor-agent login` 后重试。" + grok_signed_out: "Grok 尚未登录。请设置 XAI_API_KEY 或运行 `grok login` 后重试。" update_aria: "有可用更新 — 查看详情" update_title: "有可用更新" update_message: "使用以下命令更新当前安装。" @@ -533,6 +537,10 @@ providers: claude_home_help: "运行该 Claude 实例时使用的自定义 HOME,可隔离 .claude.json 与 .claude。" pi_home: "PI_CODING_AGENT_DIR 路径" pi_home_help: "自定义 pi 智能体目录,可隔离会话、认证、模型、提示词与扩展。" + cursor_home: "CURSOR_CONFIG_DIR 路径" + cursor_home_help: "自定义的 Cursor CLI 配置目录。" + grok_home: "GROK_HOME 路径" + grok_home_help: "自定义的 Grok 主目录,可隔离会话、配置与凭据。" home: "提供方主目录路径" home_help: "该提供方实例使用的自定义主目录或数据目录。" launch_args: "启动参数"