From 7ca720ba7500928f61a7eef07aa62dcb38c05945 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 13:50:49 +0800 Subject: [PATCH 01/19] refactor(agent): share one ACP session between ACP-speaking clients Move the protocol machinery out of acp.rs into acp_session: child and connection lifetime, stderr/EOF handling, prompt delivery acknowledgement, permission settlement, the fs/terminal client services, MCP and option mapping, the standard session/update mapping and cancellation. What differs per agent family is a crate-private Dialect: launch, session establishment (auth, new/load/resume/fork, initial mode), session/update handling including replay suppression, turn completion and usage, steering, live approval-mode changes, and vendor requests/notifications routed by their literal method names with raw params. acp.rs keeps registry recipe resolution and the generic ACP policies as the registry dialect, unchanged except for permission answers: a fixed decision now selects only an offered allow_once/reject_once option and never falls back to allow_always/reject_always, so nothing reaches the agent's persistent allowlist unless the user explicitly picked that option, and an explicit Option(id) must be one the agent offered. Pending permissions and questions are also settled before session/close on shutdown. --- crates/agent/src/acp.rs | 3266 ++---------------------------- crates/agent/src/acp_session.rs | 3321 +++++++++++++++++++++++++++++++ crates/agent/src/lib.rs | 2 + 3 files changed, 3498 insertions(+), 3091 deletions(-) create mode 100644 crates/agent/src/acp_session.rs diff --git a/crates/agent/src/acp.rs b/crates/agent/src/acp.rs index 8016cc189..ba7d5db59 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, 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)) } - } - 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..dd1c42470 --- /dev/null +++ b/crates/agent/src/acp_session.rs @@ -0,0 +1,3321 @@ +//! 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_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, +}; + +/// 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); + +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". +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 `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() + } +} + +/// 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 = self.connection.send_request(request).block_task().await; + 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 { + pub(crate) async fn emit(&self, event: AgentEvent) { + let _ = self.events.send(event).await; + } +} + +/// 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, + }) +} + +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 state = Arc::new(Mutex::new(State::new(opts.cwd.clone()))); + 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}")) + .on_receive_notification( + { + let client = client.clone(); + let dialect = dialect.clone(); + async move |args: acp::SessionNotification, _connection| { + let events = dialect.session_update(&mut client.state.lock_recover(), args); + client.emit_all(events).await; + Ok(()) + } + }, + 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!(), + ) + // 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!(), + ) + .on_receive_notification( + { + let client = client.clone(); + let dialect = dialect.clone(); + async move |notification: sdk::UntypedMessage, _connection| { + let (method, params) = notification.into_parts(); + let events = + dialect.notification(&mut client.state.lock_recover(), &method, params); + client.emit_all(events).await; + Ok(()) + } + }, + sdk::on_receive_notification!(), + ) + .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. + 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: 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, acp::Error> { + 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, + }; + 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 = 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(None); + } + session_started.store(true, Ordering::Release); + + let session = client.session(connection.clone()); + 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), + } + 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_id, + &mut turn_seq, + pending_deliveries, + ) + .await; + } + Input::Turn(outcome) => { + let id = turn_id + .take() + .expect("turn outcome requires an active turn"); + finish_turn(dialect, state, events, &id, 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(close_reason) +} + +struct TurnOutcome { + 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 mut client_capabilities = acp::ClientCapabilities::new() + .fs(acp::FileSystemCapabilities::new() + .read_text_file(true) + .write_text_file(true)) + .terminal(true); + 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() + ))); + } + }; + 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)) +} + +#[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_id: &mut Option, + 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}"); + *turn_id = Some(id.clone()); + 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 { 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. + 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; + } +} + +async fn finish_turn( + dialect: &D, + state: &Arc>, + events: &Sender, + turn_id: &str, + outcome: TurnOutcome, +) { + let end = { + let mut state = state.lock_recover(); + let tail = state.flush_text(); + state.turn = None; + let end = dialect.turn_end(&mut state, outcome.result); + (tail, end) + }; + let (tail, end) = end; + for event in tail { + let _ = events.send(event).await; + } + if let Some(message) = end.message { + let _ = events + .send(AgentEvent::Error { + message, + fatal: false, + }) + .await; + } + let _ = events + .send(AgentEvent::TurnCompleted { + turn_id: turn_id.to_string(), + status: end.status, + usage: end.usage, + }) + .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 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, + } + } +} + +/// 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 our four fixed decisions onto the agent's own permission options (so the +/// existing approval UI keeps working), 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)), + )) +} + +/// `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()), + ); + } + 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, +} + +/// 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>>, + 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, + session_id: None, + turn: None, + loading: false, + tools: HashMap::new(), + text: None, + approvals: HashMap::new(), + approval_seq: 0, + inputs: HashMap::new(), + text_seq: 0, + terminal_seq: 0, + terminals: HashMap::new(), + usage: None, + options: OptionRegistry::default(), + modes: None, + previous_non_plan_mode: None, + } + } + + /// Whether `session/load` is still answering. + pub(crate) fn loading(&self) -> bool { + self.loading + } + + /// The latest `usage_update` context figure. + pub(crate) fn usage(&self) -> Option { + self.usage + } + + 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. + 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.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(), + } +} + +/// 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.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: &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/lib.rs b/crates/agent/src/lib.rs index ccccbca13..46d887203 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; From 08d15ab323a2ee38b46c04bb2fff7b31ac91fc98 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 14:15:26 +0800 Subject: [PATCH 02/19] fix(agent): report an ACP startup failure only after the agent is gone A failed handshake (signed-out agent, broken initialize, early EOF) was sent to the caller before the child process was killed. A caller that exits on the error, such as the probe, left the agent running, reparented to init; cursor-agent does not exit on stdin EOF, so it lingered. The actor now kills and reaps the child first and reports the failure after. --- crates/agent/src/acp_session.rs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/agent/src/acp_session.rs b/crates/agent/src/acp_session.rs index dd1c42470..ded90e909 100644 --- a/crates/agent/src/acp_session.rs +++ b/crates/agent/src/acp_session.rs @@ -551,9 +551,20 @@ async fn run_actor( .await; // The connection closure owns the protocol shutdown; this is the hard - // process boundary for both graceful shutdowns and broken transports. + // 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)); @@ -575,6 +586,12 @@ async fn run_actor( .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>, @@ -590,7 +607,7 @@ async fn connected_actor( io_done: &Receiver, session_started: &AtomicBool, pending_deliveries: &Arc>>, -) -> Result, acp::Error> { +) -> Result { enum Startup { Handshake(Result<(Established, bool), AgentError>), Io(String), @@ -620,13 +637,11 @@ async fn connected_actor( } other => other, }; - let _ = ready.send(Err(err)).await; - return Ok(None); + return Ok(Exit::StartFailed(err)); } Startup::Io(reason) => { let message = stderr_tail.append_to(reason, "\n"); - let _ = ready.send(Err(AgentError::Protocol(message))).await; - return Ok(None); + return Ok(Exit::StartFailed(AgentError::Protocol(message))); } }; let session_id = established.session_id; @@ -645,7 +660,7 @@ async fn connected_actor( .await; emit_provider_options(state, events, false).await; if ready.send(Ok(())).await.is_err() { - return Ok(None); + return Ok(Exit::Closed(None)); } session_started.store(true, Ordering::Release); @@ -716,7 +731,7 @@ async fn connected_actor( } } - Ok(close_reason) + Ok(Exit::Closed(close_reason)) } struct TurnOutcome { From 9281f911769d739fba2495398891341d5223ccad Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 14:17:41 +0800 Subject: [PATCH 03/19] refactor: list the native providers once, at ProviderKind ProviderKind::NATIVE is the one ordered collection of natively maintained providers. The runtime's catalog/version/profile loops, the secret-name index, the Settings provider cards and the composer's picker rail consume it instead of keeping their own arrays; the Settings cards therefore follow the same Claude-first order as the composer and the runtime. The provider dialog copy becomes an exhaustive match, so a missing provider is a compile error instead of a panic. --- crates/agent/src/lib.rs | 9 +++++ crates/runtime/src/app/mod.rs | 8 +---- crates/runtime/src/app/providers.rs | 37 +++++++++----------- crates/runtime/src/app/sessions.rs | 2 +- crates/ui/src/composer/components/pickers.rs | 4 +-- crates/ui/src/composer/mod.rs | 6 ---- crates/ui/src/provider_dialog.rs | 25 ++++++------- crates/ui/src/store/mod.rs | 14 +++----- 8 files changed, 43 insertions(+), 62 deletions(-) diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 46d887203..4b252ab7c 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -94,6 +94,15 @@ pub enum OptionDescriptors { } impl ProviderKind { + /// The natively maintained providers, in the order provider lists present + /// them. + pub const NATIVE: [ProviderKind; 4] = [ + ProviderKind::ClaudeCode, + ProviderKind::Codex, + ProviderKind::Pi, + ProviderKind::OpenCode, + ]; + /// Provider behavior consumed by runtime and UI policy. /// /// This is deliberately one exhaustive table so adding a provider forces a 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/providers.rs b/crates/runtime/src/app/providers.rs index a5e48e660..482b294f1 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(); @@ -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( 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/ui/src/composer/components/pickers.rs b/crates/ui/src/composer/components/pickers.rs index 587a21d01..ff6f1f86c 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 @@ -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_dialog.rs b/crates/ui/src/provider_dialog.rs index 1c7ee4f24..ae1445aed 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(), @@ -974,19 +974,14 @@ 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::Acp => ["", "", "providers.home", "providers.home_help", "", ""], + } } fn path_string(path: &Option) -> String { 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 { From 3d1ac5edbb7b5c7497fee2ede9adfeb7ae0ee0dc Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 14:25:29 +0800 Subject: [PATCH 04/19] feat: add Cursor and Grok as native providers over the shared ACP session Both CLIs speak ACP as their only bidirectional protocol, so each is a dialect of the shared ACP session rather than a new JSON-RPC engine. Grok (`grok [--permission-mode M] agent [--model M] [flags] stdio`, home GROK_HOME) authenticates with the non-interactive xai.api_key method, creates sessions with session/new, resumes with session/resume, forks with _x.ai/session/fork, drops updates flagged _meta.isReplay, steers through _x.ai/interject (accepted when Grok echoes _x.ai/session/interjection), answers _x.ai/ask_user_question through a blocking user-input request, and lists its models from initialize's _meta.modelState. Cursor (`cursor-agent [flags] acp`, home CURSOR_CONFIG_DIR) initializes with its _meta client capabilities, never calls authenticate (it opens a browser when signed out) and reports a missing login instead, and resumes with session/load. Its sessions are not available yet: once a session would be established, start fails with an explicit error until the cursor/* requests are implemented and verified against a signed-in account. The shared session gains what the dialects need: a blocking user-input request that interrupt and shutdown cancel without holding the dispatch loop, session state access for vendor request handlers, and a sessionless query (spawn, initialize, ask, tear down) for model catalogs. Identity and compatibility: wire and settings names are `cursor` and `grok`; the two built-in profile ids are `native:cursor` and `native:grok`, which no user profile slug can take, so an existing user profile named "Cursor" or "Grok" keeps resolving to itself (Settings::builtin_profile_id is the one owner, also used for thread colors and secrets). The registry's `cursor` and `grok-build` agents are hidden: installed copies stay resumable for their existing sessions and removable, but are no longer offered for, or accepted as, the agent of a new session. PROTOCOL_VERSION becomes 8 because ProviderKind has no unknown fallback on the wire. Wire-option selections are persisted for every provider whose options come from the wire, not only for registry ACP agents. --- CONTRIBUTING.md | 7 +- README.md | 6 +- assets/icons/cursor.svg | 4 + assets/icons/grok.svg | 4 + crates/agent/examples/probe.rs | 51 ++- crates/agent/src/acp_session.rs | 139 +++++- crates/agent/src/cursor.rs | 139 ++++++ crates/agent/src/grok.rs | 424 ++++++++++++++++++ crates/agent/src/lib.rs | 84 +++- crates/core/src/acp.rs | 7 + crates/core/src/project.rs | 8 +- crates/core/src/provider_colors.rs | 6 +- crates/core/src/settings.rs | 80 +++- crates/protocol/src/lib.rs | 6 +- crates/runtime/src/app/command_validation.rs | 15 + crates/runtime/src/app/events.rs | 7 +- crates/runtime/src/app/orchestrate.rs | 4 + crates/runtime/src/app/providers.rs | 13 +- crates/runtime/src/app/tests.rs | 64 +++ crates/services/src/provider_probe.rs | 8 +- crates/services/src/provider_usage.rs | 6 +- crates/services/src/settings.rs | 17 +- crates/services/src/store.rs | 4 + .../src/version_check/provider_updates.rs | 4 +- .../provider_updates/installation.rs | 7 +- crates/ui/src/assets.rs | 8 + crates/ui/src/composer/components/pickers.rs | 2 +- crates/ui/src/provider_card.rs | 2 + crates/ui/src/provider_dialog.rs | 12 +- crates/ui/src/provider_status.rs | 4 + locales/en.yml | 8 + locales/zh-CN.yml | 8 + 32 files changed, 1067 insertions(+), 91 deletions(-) create mode 100644 assets/icons/cursor.svg create mode 100644 assets/icons/grok.svg create mode 100644 crates/agent/src/cursor.rs create mode 100644 crates/agent/src/grok.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 485d17272..ca2489a6d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,8 +121,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 +275,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..2c363f494 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` (sessions are not available yet) | +| [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` 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..fdee30b9d 100644 --- a/crates/agent/examples/probe.rs +++ b/crates/agent/examples/probe.rs @@ -1,17 +1,19 @@ //! Headless end-to-end probe for provider clients. //! -//! Catalog mode: `probe --list-models `. +//! Catalog mode: `probe --list-models `. //! 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 `--mode plan`, `--effort `, `--resume `, `--fork`, +//! `--leave-questions` (user-input requests stay unanswered), `--interrupt-after +//! `, `--steer `, and `--image `. Only one of the last +//! three may be used. use std::path::PathBuf; use std::time::Duration; use agent::{ AcpAgent, AcpLaunch, AgentEvent, ApprovalDecision, ApprovalMode, Attachment, InteractionMode, - ItemContent, OptionSelection, ProviderKind, SessionCommand, SessionOptions, TurnOptions, - TurnStatus, list_models, start_session, + ItemContent, OptionSelection, ProviderKind, ResumeCursor, SessionCommand, SessionOptions, + TurnOptions, TurnStatus, list_models, start_session, }; use base64::Engine as _; @@ -29,10 +31,10 @@ enum ProbeMode { fn usage() -> ! { eprintln!( - "usage: probe [cwd] \ + "usage: probe [cwd] \ [supervised|auto_edits|full_access] [acp-command args…] [flags]" ); - eprintln!(" probe --list-models "); + eprintln!(" probe --list-models "); std::process::exit(2); } @@ -42,6 +44,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(), } @@ -108,6 +112,9 @@ fn main() { let mut interaction_mode = InteractionMode::Build; let mut effort = None; + let mut resume = None; + let mut fork = false; + let mut leave_questions = false; let mut probe_mode = ProbeMode::Standard; let mut positional = Vec::new(); let mut args = args.into_iter(); @@ -125,6 +132,17 @@ fn main() { }; } "--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, "--interrupt-after" => { let seconds = args .next() @@ -210,10 +228,21 @@ fn main() { effort, probe_mode, acp, + Resumption { + resume, + fork, + leave_questions, + }, )); std::process::exit(exit_code); } +struct Resumption { + resume: Option, + fork: bool, + leave_questions: bool, +} + #[allow(clippy::too_many_arguments)] async fn run_probe( provider: ProviderKind, @@ -224,6 +253,7 @@ async fn run_probe( effort: Option, probe_mode: ProbeMode, acp: Option, + resumption: Resumption, ) -> i32 { let option_selections = effort .iter() @@ -239,8 +269,8 @@ async fn run_probe( let opts = SessionOptions { cwd, model, - resume: None, - fork: false, + resume: resumption.resume, + fork: resumption.fork, binary_path: None, approval_mode, option_selections, @@ -360,6 +390,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, diff --git a/crates/agent/src/acp_session.rs b/crates/agent/src/acp_session.rs index ded90e909..fc8bbe0da 100644 --- a/crates/agent/src/acp_session.rs +++ b/crates/agent/src/acp_session.rs @@ -36,7 +36,7 @@ use crate::{ InteractionMode, ItemContent, ItemStatus, McpRegistration, OptionDescriptor, OptionSelection, PlanStep, PlanStepStatus, ProviderCommand, ProviderCommandKind, ProviderKind, ResumeCursor, SelectOption, SessionCommand, SessionHandle, SessionOptions, ThreadItem, TokenUsage, - TurnStatus, + TurnStatus, UserInputDelivery, UserInputQuestion, }; /// Option-descriptor ids. The composer renders an ACP agent's own @@ -268,9 +268,40 @@ pub(crate) struct Session { } 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`. @@ -317,6 +348,70 @@ pub(crate) async fn start( }) } +/// 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 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).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, @@ -749,6 +844,25 @@ async fn handshake( events: &Sender, ) -> Result<(Established, bool), AgentError> { let name = dialect.name(); + let init = initialize(connection, name, launch).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, +) -> Result { let mut client_capabilities = acp::ClientCapabilities::new() .fs(acp::FileSystemCapabilities::new() .read_text_file(true) @@ -795,17 +909,7 @@ async fn handshake( ))); } }; - 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)) + Ok(init) } #[allow(clippy::too_many_arguments)] @@ -1592,6 +1696,7 @@ pub(crate) struct State { approvals: HashMap, Vec)>, approval_seq: u64, inputs: HashMap>>, + input_seq: u64, text_seq: u64, terminal_seq: u64, terminals: HashMap>, @@ -1613,6 +1718,7 @@ impl State { approvals: HashMap::new(), approval_seq: 0, inputs: HashMap::new(), + input_seq: 0, text_seq: 0, terminal_seq: 0, terminals: HashMap::new(), @@ -1628,6 +1734,11 @@ impl State { 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 @@ -1819,7 +1930,7 @@ impl State { events } acp::SessionUpdate::Plan(plan) => vec![AgentEvent::PlanUpdated { - turn_id: self.turn.clone(), + turn_id: self.turn().map(str::to_owned), explanation: None, steps: plan.entries.iter().map(plan_step).collect(), }], @@ -2258,7 +2369,7 @@ impl Client { state.approval_seq += 1; ( format!("acp-approval-{}", state.approval_seq), - state.turn.clone(), + state.turn().map(str::to_owned), ) }; let request = approval_request(request_id.clone(), turn, &args.tool_call, &args.options); diff --git a/crates/agent/src/cursor.rs b/crates/agent/src/cursor.rs new file mode 100644 index 000000000..b487f5c30 --- /dev/null +++ b/crates/agent/src/cursor.rs @@ -0,0 +1,139 @@ +//! Cursor: `cursor-agent [flags] acp`, an ACP agent with Cursor's `cursor/*` +//! dialect. +//! +//! The protocol machinery is [`crate::acp_session`]; this module is Cursor's +//! dialect. + +use std::path::PathBuf; + +use agent_client_protocol::schema::v1 as acp; +use serde_json::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, LaunchEnv, ModelSpec, ProviderKind, + SessionHandle, SessionOptions, +}; + +/// Start (or resume) a Cursor session. +pub async fn start(opts: SessionOptions) -> Result { + acp_session::start(ProviderKind::Cursor, Cursor, opts).await +} + +/// No catalog: Cursor sessions are not available yet ([`start`]). +pub async fn list_models( + _binary_path: Option, + _launch_env: LaunchEnv, +) -> Result, AgentError> { + Ok(Vec::new()) +} + +struct Cursor; + +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 = opts.extra_args.clone(); + 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 opts = 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"])); + // `authenticate` opens a browser when Cursor is signed out, so it is + // never driven from here: a missing login is reported instead. + let established = match resumed { + Some(session_id) => setup + .load( + acp::LoadSessionRequest::new(session_id.to_string(), opts.cwd.clone()) + .mcp_servers(mcp_servers), + ) + .await + .map(|_| ()), + None => setup + .connection + .send_request( + acp::NewSessionRequest::new(opts.cwd.clone()).mcp_servers(mcp_servers), + ) + .block_task() + .await + .map(|_| ()), + }; + match established { + Err(err) if is_auth_required(&err) => Err(AgentError::Provider(format!( + "Cursor is not signed in: {}. Run `cursor-agent login`, then start the session again.", + describe(&err) + ))), + Err(err) => Err(AgentError::Protocol(format!( + "Cursor could not start a session: {}", + describe(&err) + ))), + Ok(()) => Err(AgentError::Provider( + "Cursor sessions are not available yet in this build of Tcode.".into(), + )), + } + } + + 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(); + } + state.apply_update(notification.update) + } + + fn turn_end( + &self, + _state: &mut State, + result: Result, + ) -> TurnEnd { + let (status, message) = prompt_status(&result); + // 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) {} +} diff --git a/crates/agent/src/grok.rs b/crates/agent/src/grok.rs new file mode 100644 index 000000000..ae780c46f --- /dev/null +++ b/crates/agent/src/grok.rs @@ -0,0 +1,424 @@ +//! 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::VecDeque; +use std::path::PathBuf; +use std::sync::Mutex; + +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, + ProviderKind, ResumeCursor, SessionHandle, SessionOptions, 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 INTERJECT: &str = "_x.ai/interject"; +const INTERJECTION: &str = "_x.ai/session/interjection"; +const FORK: &str = "_x.ai/session/fork"; + +/// Start (or resume, or fork) a Grok session. +pub async fn start(opts: SessionOptions) -> Result { + acp_session::start(ProviderKind::Grok, Grok::default(), 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) = init.meta.as_ref().and_then(|meta| meta.get("modelState")) else { + return Vec::new(); + }; + let current = state.get("currentModelId").and_then(Value::as_str); + state + .get("availableModels") + .and_then(Value::as_array) + .into_iter() + .flatten() + .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() +} + +#[derive(Default)] +struct Grok { + /// Steers sent with `_x.ai/interject`, oldest first, until Grok echoes + /// them back as consumed. + steers: Mutex>, +} + +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")?; + let permission_mode = match opts.approval_mode { + ApprovalMode::Supervised | ApprovalMode::ReadOnly => "default", + ApprovalMode::AutoAcceptEdits => "acceptEdits", + 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()]); + } + 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); + 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) = 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, + ) + } + 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) + } + }; + setup.adopt(modes.as_ref(), config_options.as_deref()); + 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 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(); + } + state.apply_update(notification.update) + } + + fn turn_end( + &self, + _state: &mut State, + result: Result, + ) -> TurnEnd { + let (status, message) = prompt_status(&result); + TurnEnd { + status, + message, + usage: None, + } + } + + async fn steer( + &self, + session: &Session, + request_id: String, + text: String, + _attachments: Vec, + ) { + let Some(session_id) = session.id() else { + return; + }; + self.steers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .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 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .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. + } + + fn handles_request(&self, method: &str) -> bool { + method == ASK_USER_QUESTION + } + + async fn request( + &self, + session: Session, + method: String, + params: Value, + ) -> Result { + debug_assert_eq!(method, ASK_USER_QUESTION); + 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 { + if method != INTERJECTION { + log::trace!("grok: {method} {params}"); + return Vec::new(); + } + let text = params.get("text").and_then(Value::as_str); + let mut steers = self + .steers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + 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 }] + } +} + +/// 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() +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 4b252ab7c..93479a547 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -23,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; @@ -44,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, } @@ -96,11 +102,13 @@ pub enum OptionDescriptors { impl ProviderKind { /// The natively maintained providers, in the order provider lists present /// them. - pub const NATIVE: [ProviderKind; 4] = [ + 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. @@ -186,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, + }, } } @@ -195,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)] @@ -355,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, } @@ -371,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. @@ -651,7 +701,7 @@ 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 + /// The answer key. Claude and Grok: the complete question text (both index /// answers by question text). Codex: the native question id. pub id: String, pub header: String, @@ -749,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()), @@ -764,6 +816,7 @@ 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: its `--permission-mode` at launch. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ApprovalMode { @@ -935,6 +988,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, } } @@ -1831,6 +1886,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/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/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 482b294f1..f65d7f8d8 100644 --- a/crates/runtime/src/app/providers.rs +++ b/crates/runtime/src/app/providers.rs @@ -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. @@ -828,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() @@ -921,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/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..a4ec2cb1f 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,8 +124,10 @@ 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::Acp => None, + // Authentication over ACP is surfaced by the session protocol. + // `cursor-agent status` ignores CURSOR_API_KEY, so it cannot tell a + // signed-out Cursor from one using an API key. + ProviderKind::Cursor | ProviderKind::Grok | ProviderKind::Acp => None, }; finalize_probe(checked_at, version, auth) 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 ff6f1f86c..cf669d173 100644 --- a/crates/ui/src/composer/components/pickers.rs +++ b/crates/ui/src/composer/components/pickers.rs @@ -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(); 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 ae1445aed..18eafe345 100644 --- a/crates/ui/src/provider_dialog.rs +++ b/crates/ui/src/provider_dialog.rs @@ -976,11 +976,13 @@ type ProviderCopy = [&'static str; 6]; #[rustfmt::skip] 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::Acp => ["", "", "providers.home", "providers.home_help", "", ""], + 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", "", ""], } } 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/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: "启动参数" From 634994c76970a8d597e7b3a127579f5213a416ec Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 15:11:35 +0800 Subject: [PATCH 05/19] feat(agent): let an ACP dialect keep its own tools, options and approvals Three additive Dialect hooks, each defaulting to today's behaviour: - client_services(): whether `initialize` offers tcode's fs/* and terminal/* services. An agent offered them routes its file and shell tools through tcode instead of its own implementation. - owned_config_options(): session config options (by configId) that tcode already controls elsewhere, such as the composer's model picker. They stay in the option registry (the session's model is still read from it) but are left out of ProviderOptions. - auto_approves(tool_call): whether a permission request is approved without asking, for an approval mode the agent does not apply itself. The shared session then selects the request's allow-once option only; without one the user is asked, so no automatic answer ever records a persistent allow/reject rule. --- crates/agent/src/acp_session.rs | 104 +++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 23 deletions(-) diff --git a/crates/agent/src/acp_session.rs b/crates/agent/src/acp_session.rs index fc8bbe0da..4ba25e3bc 100644 --- a/crates/agent/src/acp_session.rs +++ b/crates/agent/src/acp_session.rs @@ -93,7 +93,8 @@ macro_rules! request_handler { /// 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". +/// "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; @@ -156,6 +157,28 @@ pub(crate) trait Dialect: Send + Sync + 'static { 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. @@ -369,6 +392,7 @@ where 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()) @@ -388,10 +412,12 @@ where .builder() .name(format!("tcode-acp-query-{name}")) .connect_with(sdk::ByteStreams::new(stdin, stdout), async |connection| { - Ok(match initialize(&connection, &name, &launch).await { - Ok(init) => query(&connection, &init).await, - Err(err) => Err(err), - }) + Ok( + match initialize(&connection, &name, &launch, client_services).await { + Ok(init) => query(&connection, &init).await, + Err(err) => Err(err), + }, + ) }) .await; let _ = child.kill(); @@ -511,7 +537,9 @@ async fn run_actor( }) .detach(); - let state = Arc::new(Mutex::new(State::new(opts.cwd.clone()))); + 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(), @@ -539,7 +567,28 @@ async fn run_actor( sdk::on_receive_notification!(), ) .on_receive_request( - request_handler!(client, acp::RequestPermissionRequest, request_permission), + { + 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( @@ -844,7 +893,7 @@ async fn handshake( events: &Sender, ) -> Result<(Established, bool), AgentError> { let name = dialect.name(); - let init = initialize(connection, name, launch).await?; + let init = initialize(connection, name, launch, dialect.client_services()).await?; let setup = Setup { connection, opts, @@ -862,12 +911,13 @@ 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(true) - .write_text_file(true)) - .terminal(true); + .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, @@ -1452,6 +1502,15 @@ fn approval_outcome( )) } +/// 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 { @@ -1702,6 +1761,8 @@ pub(crate) struct State { terminals: HashMap>, usage: Option, options: OptionRegistry, + /// [`Dialect::owned_config_options`]. + owned_options: &'static [&'static str], modes: Option, previous_non_plan_mode: Option, } @@ -1724,6 +1785,7 @@ impl State { terminals: HashMap::new(), usage: None, options: OptionRegistry::default(), + owned_options: &[], modes: None, previous_non_plan_mode: None, } @@ -1780,19 +1842,15 @@ impl State { } 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: self - .options - .records - .iter() - .map(|record| record.descriptor.clone()) - .collect(), - selections: self - .options - .records - .iter() - .map(|record| record.selection.clone()) - .collect(), + descriptors: surfaced().map(|record| record.descriptor.clone()).collect(), + selections: surfaced().map(|record| record.selection.clone()).collect(), } } From 683af9ef5f5ea3daca3f0c537af46283cf777df7 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 15:11:49 +0800 Subject: [PATCH 06/19] feat(grok): complete the Grok session client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against grok 1.0.46 with a scripted model backend (no xAI account or live model). - Grok runs its own file and shell tools. Offered tcode's client services it sends every command to terminal/create as one `/bin/bash -lc '…'` string, which cannot be spawned, and every read and edit to fs/*, which tcode's cwd containment rejected for Grok's canonical paths; so no Grok shell command or edit worked. - Tool items: Grok's shell tool resends the whole output so far with every update, and the item shows exactly that, with the exit code only once the command has finished. The model's description of a command is no longer shown as its output, and Grok's typed rawOutput never becomes display text (a command printing nothing showed it as JSON). MCP calls through use_tool show the MCP result text. - Usage: each model call's response_completed is a context observation (TokenUsage against the model's totalContextTokens); the session/prompt result's _meta is the turn's usage, reported once on TurnCompleted. turn_completed and prompt_complete notifications never complete a turn. - Failures: a failed turn shows Grok's own message from the -32603 error data; retry_state retrying becomes a non-fatal error like Codex's willRetry; image_dropped notes become a warning; background_tasks maps to BackgroundTasksChanged (running tasks); auto_compact_started and auto_compact_completed map to ContextCompacted. - Plan mode is native: Grok's default/plan modes (which session/set_mode takes and current_mode_update reports, but Grok does not list) back the Build/Plan toggle, including Grok entering plan mode itself. A continued session is told its mode, because Grok keeps plan mode across processes without reporting it. _x.ai/exit_plan_mode becomes a ProposedPlan and is answered `request_changes`, which keeps Grok planning: the decision is the user's, through the plan flow; approving would let Grok implement first, and holding the request would keep the turn open. An unanswered request cancelled the whole turn. - Approval modes: `--permission-mode acceptEdits` still asks before every edit, so AutoAcceptEdits launches `default` and approves edit-kind requests once itself; ReadOnly and Supervised launch `default` (reads unprompted, mutations ask); FullAccess launches `bypassPermissions`, which survives leaving plan mode. - Model and effort: the composer's model picker owns the model (the wire `model` option is no longer a second selector); reasoning effort stays a wire option, applied live, and is passed as `--reasoning-effort` when a session starts again. The probe gains `--mcp `, and `--effort` sets Grok's wire option. The recorded fixture (tests/fixtures/grok) is replayed by a stand-in Grok through the production session path. --- crates/agent/examples/probe.rs | 31 +- crates/agent/src/acp_session.rs | 17 + crates/agent/src/grok.rs | 799 ++++++++++++++++-- crates/agent/src/lib.rs | 3 +- crates/agent/tests/fixtures/grok/README.md | 53 ++ .../tests/fixtures/grok/resumed_turn.jsonl | 95 +++ 6 files changed, 942 insertions(+), 56 deletions(-) create mode 100644 crates/agent/tests/fixtures/grok/README.md create mode 100644 crates/agent/tests/fixtures/grok/resumed_turn.jsonl diff --git a/crates/agent/examples/probe.rs b/crates/agent/examples/probe.rs index fdee30b9d..3f19cd176 100644 --- a/crates/agent/examples/probe.rs +++ b/crates/agent/examples/probe.rs @@ -3,17 +3,18 @@ //! Catalog mode: `probe --list-models `. //! Turn mode: `probe [cwd] [approval] [acp-command args…] [flags]`. //! Flags are `--mode plan`, `--effort `, `--resume `, `--fork`, -//! `--leave-questions` (user-input requests stay unanswered), `--interrupt-after -//! `, `--steer `, and `--image `. Only one of the last -//! three may be used. +//! `--leave-questions` (user-input requests stay unanswered), `--mcp +//! ` (an HTTP MCP server registered as tcode registers its own), +//! `--interrupt-after `, `--steer `, and `--image `. Only +//! one of the last three may be used. use std::path::PathBuf; use std::time::Duration; use agent::{ AcpAgent, AcpLaunch, AgentEvent, ApprovalDecision, ApprovalMode, Attachment, InteractionMode, - ItemContent, OptionSelection, ProviderKind, ResumeCursor, 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 _; @@ -115,6 +116,7 @@ fn main() { let mut resume = None; let mut fork = false; let mut leave_questions = false; + let mut mcp_servers = Vec::new(); let mut probe_mode = ProbeMode::Standard; let mut positional = Vec::new(); let mut args = args.into_iter(); @@ -143,6 +145,14 @@ fn main() { } "--fork" => fork = true, "--leave-questions" => leave_questions = true, + "--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() @@ -228,6 +238,7 @@ fn main() { effort, probe_mode, acp, + mcp_servers, Resumption { resume, fork, @@ -253,12 +264,18 @@ async fn run_probe( effort: Option, probe_mode: ProbeMode, acp: Option, + mcp_servers: Vec, resumption: Resumption, ) -> 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(); @@ -275,7 +292,7 @@ async fn run_probe( approval_mode, option_selections, interaction_mode, - mcp_servers: Vec::new(), + mcp_servers, launch_env: Default::default(), extra_args: Vec::new(), acp, diff --git a/crates/agent/src/acp_session.rs b/crates/agent/src/acp_session.rs index 4ba25e3bc..c789ce12f 100644 --- a/crates/agent/src/acp_session.rs +++ b/crates/agent/src/acp_session.rs @@ -46,6 +46,23 @@ 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; diff --git a/crates/agent/src/grok.rs b/crates/agent/src/grok.rs index ae780c46f..a16bf021b 100644 --- a/crates/agent/src/grok.rs +++ b/crates/agent/src/grok.rs @@ -4,9 +4,9 @@ //! The protocol machinery is [`crate::acp_session`]; this module is Grok's //! dialect. -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; -use std::sync::Mutex; +use std::sync::{Mutex, MutexGuard}; use agent_client_protocol::{UntypedMessage, schema::v1 as acp}; use serde_json::{Map, Value, json}; @@ -16,8 +16,9 @@ use crate::acp_session::{ prompt_status, }; use crate::{ - AgentError, AgentEvent, ApprovalMode, Attachment, InteractionMode, LaunchEnv, ModelSpec, - ProviderKind, ResumeCursor, SessionHandle, SessionOptions, UserInputOption, UserInputQuestion, + 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 @@ -26,13 +27,24 @@ use crate::{ 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 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 { - acp_session::start(ProviderKind::Grok, Grok::default(), opts).await + 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 @@ -66,15 +78,11 @@ pub async fn list_models( /// `initialize`'s `_meta.modelState`. fn catalog(init: &acp::InitializeResponse) -> Vec { - let Some(state) = init.meta.as_ref().and_then(|meta| meta.get("modelState")) else { + let Some(state) = model_state(init) else { return Vec::new(); }; let current = state.get("currentModelId").and_then(Value::as_str); - state - .get("availableModels") - .and_then(Value::as_array) - .into_iter() - .flatten() + available_models(state) .filter_map(|model| { let id = model.get("modelId")?.as_str()?; Some(ModelSpec { @@ -91,11 +99,168 @@ fn catalog(init: &acp::InitializeResponse) -> Vec { .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, +} + +/// 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, +} + +impl Grok { + fn models(&self) -> MutexGuard<'_, Models> { + self.models + .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 models = self.models(); + 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() + } + } + + /// 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, 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(); + vec![AgentEvent::BackgroundTasksChanged { count: running }] + } + 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 { @@ -105,9 +270,13 @@ impl Dialect for 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 => "default", - ApprovalMode::AutoAcceptEdits => "acceptEdits", + ApprovalMode::Supervised | ApprovalMode::ReadOnly | ApprovalMode::AutoAcceptEdits => { + "default" + } ApprovalMode::FullAccess => "bypassPermissions", }; let mut args = vec![ @@ -118,6 +287,11 @@ impl Dialect for Grok { 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 { @@ -130,6 +304,22 @@ impl Dialect for Grok { 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, @@ -164,7 +354,7 @@ impl Dialect for Grok { .as_ref() .and_then(|cursor| cursor.str_field(&["session_id"])) .map(str::to_string); - let (session_id, modes, config_options) = match resumed { + let (session_id, modes, config_options, existing) = match resumed { Some(source) => { let session_id = if opts.fork { fork(connection, &source, opts).await? @@ -192,6 +382,7 @@ impl Dialect for Grok { acp::SessionId::new(session_id), resumed.modes, resumed.config_options, + true, ) } None => { @@ -211,15 +402,49 @@ impl Dialect for Grok { )) } })?; - (created.session_id, created.modes, created.config_options) + ( + created.session_id, + created.modes, + created.config_options, + false, + ) } }; - setup.adopt(modes.as_ref(), config_options.as_deref()); - if opts.interaction_mode == InteractionMode::Plan || setup.in_plan_mode() { - setup - .apply_interaction_mode(&session_id, opts.interaction_mode) + + 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, @@ -242,7 +467,16 @@ impl Dialect for Grok { if replayed { return Vec::new(); } - state.apply_update(notification.update) + let mut update = notification.update; + let shell = match &mut update { + acp::SessionUpdate::ToolCallUpdate(update) => 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( @@ -250,11 +484,18 @@ impl Dialect for Grok { _state: &mut State, result: Result, ) -> TurnEnd { - let (status, message) = prompt_status(&result); + let (status, message) = match (prompt_status(&result), &result) { + ((TurnStatus::Failed, _), Err(err)) => (TurnStatus::Failed, Some(failure_message(err))), + (outcome, _) => outcome, + }; TurnEnd { status, message, - usage: None, + usage: result + .as_ref() + .ok() + .and_then(|response| response.meta.as_ref()) + .map(|meta| self.turn_usage(meta)), } } @@ -268,10 +509,7 @@ impl Dialect for Grok { let Some(session_id) = session.id() else { return; }; - self.steers - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push_back((text.clone(), request_id.clone())); + self.steers().push_back((text.clone(), request_id.clone())); let sent = session .connection .send_request(UntypedMessage { @@ -281,10 +519,7 @@ impl Dialect for Grok { .block_task() .await; if let Err(err) = sent { - self.steers - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .retain(|(_, pending)| pending != &request_id); + self.steers().retain(|(_, pending)| pending != &request_id); session .emit(AgentEvent::Warning { message: format!("Grok did not accept the steer: {}", describe(&err)), @@ -298,8 +533,26 @@ impl Dialect for Grok { // 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 { - method == ASK_USER_QUESTION + matches!(method, ASK_USER_QUESTION | EXIT_PLAN_MODE) } async fn request( @@ -308,7 +561,27 @@ impl Dialect for Grok { method: String, params: Value, ) -> Result { - debug_assert_eq!(method, ASK_USER_QUESTION); + 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) }), @@ -317,26 +590,132 @@ impl Dialect for Grok { } fn notification(&self, _state: &mut State, method: &str, params: Value) -> Vec { - if method != INTERJECTION { - log::trace!("grok: {method} {params}"); - return Vec::new(); + match method { + INTERJECTION => self.steer_accepted(¶ms), + SESSION_NOTIFICATION => params + .get("update") + .map(|update| self.session_notification(update)) + .unwrap_or_default(), + _ => Vec::new(), } - let text = params.get("text").and_then(Value::as_str); - let mut steers = self - .steers - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - 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 }] } } +/// 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, @@ -422,3 +801,327 @@ fn answer_lists(answers: Map) -> Map { }) .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, + }; + + /// A resumed Grok 1.0.46 session under AutoAcceptEdits, recorded on the + /// wire in both directions (provenance: `tests/fixtures/grok/README.md`). + const RESUMED_TURN: &str = include_str!("../tests/fixtures/grok/resumed_turn.jsonl"); + const REPLAY_AGENT: &str = "TCODE_GROK_REPLAY_AGENT"; + const TEST_NAME: &str = "grok::tests::recorded_resumed_turn_maps_to_the_canonical_stream"; + + /// 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() { + 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 RESUMED_TURN.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 this test as [`replay_agent`]; whatever + /// arguments Tcode launches Grok with are left unread. + fn stand_in_binary(dir: &std::path::Path) -> PathBuf { + 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_NAME} --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_NAME} --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 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() + } + + #[test] + fn recorded_resumed_turn_maps_to_the_canonical_stream() { + if std::env::var_os(REPLAY_AGENT).is_some() { + return replay_agent(); + } + let dir = std::env::temp_dir().join(format!("tcode-grok-replay-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let opts = SessionOptions { + cwd: dir.clone(), + model: None, + resume: Some(ResumeCursor( + json!({ "session_id": "01a10095-e65c-7983-a817-6884b010f1ae" }), + )), + fork: false, + binary_path: Some(stand_in_binary(&dir)), + approval_mode: ApprovalMode::AutoAcceptEdits, + option_selections: vec![OptionSelection { + id: "acp:cfg:reasoning_effort".into(), + value: json!("low"), + }], + interaction_mode: InteractionMode::Build, + mcp_servers: vec![McpRegistration { + name: "tcode_probe".into(), + url: "http://127.0.0.1:18433/mcp".into(), + bearer_token: "tcode-secret".into(), + }], + launch_env: LaunchEnv { + env: vec![(REPLAY_AGENT.into(), "1".into())], + home: None, + }, + extra_args: Vec::new(), + acp: None, + }; + + let events = smol::block_on(smol::future::or( + async { + let handle = start(opts).await.unwrap(); + handle + .commands + .send(SessionCommand::SendTurn { + delivery_id: 7, + text: "Count, edit, ask and echo".into(), + options: None, + attachments: Vec::new(), + }) + .await + .unwrap(); + let mut events = Vec::new(); + 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 { .. } => 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:#?}" + ); + 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)); + } +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 93479a547..b3617e65c 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -816,7 +816,8 @@ 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: its `--permission-mode` at launch. +/// - Grok: `--permission-mode` default / bypassPermissions at launch; under +/// AutoAcceptEdits tcode approves Grok's edit permission requests once. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ApprovalMode { diff --git a/crates/agent/tests/fixtures/grok/README.md b/crates/agent/tests/fixtures/grok/README.md new file mode 100644 index 000000000..9a8bdedad --- /dev/null +++ b/crates/agent/tests/fixtures/grok/README.md @@ -0,0 +1,53 @@ +# Grok session fixture + +`resumed_turn.jsonl` 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": }`. + +## 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. 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"}}}} From 7d8d53074ef7d01b07e67ac5224f18aad08953cc Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 15:11:55 +0800 Subject: [PATCH 07/19] feat(services): probe Grok authentication from `grok models` `grok models` exits 0 and opens with the CLI's authentication state. Grok 1.0.46 prints `You are not authenticated.` (reported Unauthenticated) or `You are using XAI_API_KEY.` (Authenticated, "xAI API Key"; the key is configured, not validated). Any other first line, such as a browser sign-in whose wording was not observed, stays indeterminate. --- crates/services/src/provider_probe.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/services/src/provider_probe.rs b/crates/services/src/provider_probe.rs index a4ec2cb1f..814368537 100644 --- a/crates/services/src/provider_probe.rs +++ b/crates/services/src/provider_probe.rs @@ -124,15 +124,35 @@ 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) } + 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. // `cursor-agent status` ignores CURSOR_API_KEY, so it cannot tell a // signed-out Cursor from one using an API key. - ProviderKind::Cursor | ProviderKind::Grok | ProviderKind::Acp => None, + ProviderKind::Cursor | ProviderKind::Acp => None, }; finalize_probe(checked_at, version, auth) } +/// 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, From 1f937a9a647b5d33246fc41c7e181f06aaafcc58 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 15:36:31 +0800 Subject: [PATCH 08/19] fix(agent): read ACP updates sent right after session/load as live `Setup::load` cleared its replay flag only once the awaiting task resumed, and `block_task` lets the dispatch loop read the next message first. An update the agent sends as soon as it has answered `session/load` was then taken for replay and dropped: Cursor's slash commands after a resume (`available_commands_update`, sent right after the response) were lost. The flag is now cleared in an ordered response callback, which holds the dispatch loop until it has run, so nothing after the response can be read as replay. The Cursor client's load test sends the response and the next update in one write and fails without this. --- crates/agent/src/acp_session.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/agent/src/acp_session.rs b/crates/agent/src/acp_session.rs index c789ce12f..befe665ad 100644 --- a/crates/agent/src/acp_session.rs +++ b/crates/agent/src/acp_session.rs @@ -265,7 +265,25 @@ impl Setup<'_> { request: acp::LoadSessionRequest, ) -> Result { self.state.lock_recover().loading = true; - let loaded = self.connection.send_request(request).block_task().await; + 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 } From a1fcd17acf4821c45454735259e4b70b3c6200af Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 15:36:44 +0800 Subject: [PATCH 09/19] fix(agent): drop ACP config options the agent no longer offers `configOptions` in `session/set_config_option` responses and `config_option_update` is the agent's full set, but the option registry only upserted, so an option the agent stopped offering stayed in the composer. An agent whose options depend on the model (Cursor's model parameters: a new model brings its own and drops the last one's) kept showing the previous model's parameters, and setting one failed. A config list now replaces the config options; the session modes are kept. --- crates/agent/src/acp_session.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/agent/src/acp_session.rs b/crates/agent/src/acp_session.rs index befe665ad..4d0d06979 100644 --- a/crates/agent/src/acp_session.rs +++ b/crates/agent/src/acp_session.rs @@ -1648,6 +1648,14 @@ impl OptionRegistry { 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() { // Protocol 1.2 replaced the standalone model state/set-model RPC // with categorized config options. Preserve tcode's canonical ids From 8fd37464345d582ea60f1e6ab3cb4c85ffdc3902 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 15:36:58 +0800 Subject: [PATCH 10/19] feat(agent): hand an ACP dialect the session updates the schema cannot parse `session/update` notifications went to a typed handler. One whose `sessionUpdate` kind the schema does not know (Cursor's `subagent_spawned` and `subagent_state_update`) failed to parse and the SDK dropped it with a warning, so no dialect could see it. Notifications are now received untyped: a `session/update` the schema parses goes to `Dialect::session_update` as before, and anything else, a vendor update kind included, to `Dialect::notification` with its literal method and raw params, in arrival order. `State::new` and `State::flush_text` become crate-visible so a dialect can run the standard mapping for an agent's own sub-sessions. --- crates/agent/src/acp_session.rs | 42 ++++++++++++++++----------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/crates/agent/src/acp_session.rs b/crates/agent/src/acp_session.rs index 4d0d06979..31c585d15 100644 --- a/crates/agent/src/acp_session.rs +++ b/crates/agent/src/acp_session.rs @@ -24,6 +24,7 @@ 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; @@ -168,9 +169,10 @@ pub(crate) trait Dialect: Send + Sync + 'static { async move { Err(acp::Error::method_not_found().data(method)) } } - /// An agent→client notification other than `session/update`, by its - /// literal method name with raw params. Runs in arrival order with the - /// session updates. + /// 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() } @@ -589,12 +591,24 @@ async fn run_actor( 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 |args: acp::SessionNotification, _connection| { - let events = dialect.session_update(&mut client.state.lock_recover(), args); + 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(()) } @@ -689,20 +703,6 @@ async fn run_actor( }, sdk::on_receive_request!(), ) - .on_receive_notification( - { - let client = client.clone(); - let dialect = dialect.clone(); - async move |notification: sdk::UntypedMessage, _connection| { - let (method, params) = notification.into_parts(); - let events = - dialect.notification(&mut client.state.lock_recover(), &method, params); - client.emit_all(events).await; - Ok(()) - } - }, - sdk::on_receive_notification!(), - ) .connect_with(transport, { let session_started = session_started.clone(); let stderr_tail = stderr_tail.clone(); @@ -1811,7 +1811,7 @@ pub(crate) struct State { } impl State { - fn new(cwd: PathBuf) -> Self { + pub(crate) fn new(cwd: PathBuf) -> Self { Self { cwd, session_id: None, @@ -1898,7 +1898,7 @@ impl State { } /// Close the open text block, emitting its final `ItemCompleted`. - fn flush_text(&mut self) -> Vec { + pub(crate) fn flush_text(&mut self) -> Vec { let Some(stream) = self.text.take() else { return Vec::new(); }; From 162e59e28be672abe85bc8847d9b9395eaf73c50 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 15:37:39 +0800 Subject: [PATCH 11/19] feat(cursor): run Cursor sessions over its ACP dialect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor sessions no longer stop at "not available yet". What follows was read from the shipped source of cursor-agent 2026.10.01-e373342; only the signed-out handshake has been observed from the binary. No signed-in session has run. - Catalog: `cursor/list_available_models`, whose model values are what `session/set_config_option` accepts. Signed out, it fails with the same sign-in guidance as a session instead of an empty catalog. - Sessions: `session/new`, or `session/load` for a resume, whose failure is an error and never a fresh session; its replay is dropped. `authenticate` is never sent (signed out, it starts a browser login). The composer's model is applied with `session/set_config_option` after either, since a loaded conversation comes back on its last model; a model Cursor rejects fails the start. FullAccess launches with `--force`. - Turns: a run Cursor reports as its failure text (`"\n\nError: …"` or an account action, then `end_turn`) completes as failed with the text kept; a tool is judged by its `rawOutput` (exit code, `error`, `rejected`, `permissionDenied`), since Cursor reports every finished tool `completed`, and a shell result shows its output as text. - `cursor/ask_question` becomes a blocking question; answers go back as option ids, and a typed answer Cursor cannot take is reported, not dropped silently. `cursor/create_plan` becomes the proposed plan and is answered `accepted` without a plan URI, which has Cursor save the plan as its own CLI does without asking; the user builds or dismisses it through the proposed-plan flow. `cursor/update_todos` becomes the plan, merged by id when asked to. `cursor/task` and `cursor/generate_image` are acknowledged. - Subagents (`_meta.subagents`): a task tool is a Subagent item; `subagent_spawned`, `subagent_state_update` and the subagent session's own updates become its status and its child items. The tests drive `start` and `list_models` against a stand-in that relays its stdio to the test, which answers from the recorded signed-out lines and from source-derived messages labelled as such (`tests/fixtures/cursor/PROVENANCE.md`). The probe gains `--binary`, `--model` and a `read_only` approval mode. --- README.md | 2 +- crates/agent/examples/probe.rs | 56 +- crates/agent/src/cursor.rs | 582 +++++++++++- crates/agent/src/cursor/tests.rs | 855 ++++++++++++++++++ crates/agent/src/cursor/updates.rs | 384 ++++++++ crates/agent/src/lib.rs | 4 +- .../agent/tests/fixtures/cursor/PROVENANCE.md | 81 ++ .../agent/tests/fixtures/cursor/cursor-agent | 18 + .../fixtures/cursor/recorded_signed_out.jsonl | 3 + .../tests/fixtures/cursor/source_derived.json | 488 ++++++++++ .../fixtures/cursor/status_signed_in.json | 10 + .../fixtures/cursor/status_signed_out.json | 7 + 12 files changed, 2424 insertions(+), 66 deletions(-) create mode 100644 crates/agent/src/cursor/tests.rs create mode 100644 crates/agent/src/cursor/updates.rs create mode 100644 crates/agent/tests/fixtures/cursor/PROVENANCE.md create mode 100755 crates/agent/tests/fixtures/cursor/cursor-agent create mode 100644 crates/agent/tests/fixtures/cursor/recorded_signed_out.jsonl create mode 100644 crates/agent/tests/fixtures/cursor/source_derived.json create mode 100644 crates/agent/tests/fixtures/cursor/status_signed_in.json create mode 100644 crates/agent/tests/fixtures/cursor/status_signed_out.json diff --git a/README.md b/README.md index 2c363f494..dc193aab9 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ 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` (sessions are not available yet) | +| [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 diff --git a/crates/agent/examples/probe.rs b/crates/agent/examples/probe.rs index 3f19cd176..6b6af3545 100644 --- a/crates/agent/examples/probe.rs +++ b/crates/agent/examples/probe.rs @@ -1,12 +1,12 @@ //! 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 `, `--resume `, `--fork`, -//! `--leave-questions` (user-input requests stay unanswered), `--mcp -//! ` (an HTTP MCP server registered as tcode registers its own), -//! `--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), `--interrupt-after `, `--steer `, +//! and `--image `. Only one of the last three may be used. use std::path::PathBuf; use std::time::Duration; @@ -33,9 +33,11 @@ enum ProbeMode { fn usage() -> ! { eprintln!( "usage: probe [cwd] \ - [supervised|auto_edits|full_access] [acp-command args…] [flags]" + [supervised|read_only|auto_edits|full_access] [acp-command args…] [flags]" + ); + eprintln!( + " probe --list-models [--binary ]" ); - eprintln!(" probe --list-models "); std::process::exit(2); } @@ -96,8 +98,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 @@ -112,6 +119,8 @@ 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; @@ -133,6 +142,8 @@ 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()); @@ -190,15 +201,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); } }; @@ -244,6 +260,8 @@ fn main() { fork, leave_questions, }, + binary, + model, )); std::process::exit(exit_code); } @@ -266,6 +284,8 @@ async fn run_probe( acp: Option, mcp_servers: Vec, 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 { @@ -279,16 +299,16 @@ async fn run_probe( 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: resumption.resume, fork: resumption.fork, - binary_path: None, + binary_path, approval_mode, option_selections, interaction_mode, diff --git a/crates/agent/src/cursor.rs b/crates/agent/src/cursor.rs index b487f5c30..681b09b64 100644 --- a/crates/agent/src/cursor.rs +++ b/crates/agent/src/cursor.rs @@ -1,37 +1,216 @@ -//! Cursor: `cursor-agent [flags] acp`, an ACP agent with Cursor's `cursor/*` -//! dialect. +//! 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. +//! 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::schema::v1 as acp; -use serde_json::json; +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, LaunchEnv, ModelSpec, ProviderKind, - SessionHandle, SessionOptions, + 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 { - acp_session::start(ProviderKind::Cursor, Cursor, opts).await + let cursor = Cursor::new(&opts); + acp_session::start(ProviderKind::Cursor, cursor, opts).await } -/// No catalog: Cursor sessions are not available yet ([`start`]). +/// 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, + binary_path: Option, + launch_env: LaunchEnv, ) -> Result, AgentError> { - Ok(Vec::new()) + 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() } -struct Cursor; +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 { + 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 { + 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 { @@ -42,7 +221,11 @@ impl Dialect for Cursor { // 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 = opts.extra_args.clone(); + 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 { @@ -54,7 +237,7 @@ impl Dialect for Cursor { } async fn establish(&self, setup: &Setup<'_>) -> Result { - let opts = setup.opts; + let (connection, opts) = (setup.connection, setup.opts); if opts.fork { return Err(AgentError::Protocol( "session fork is not supported for this provider".into(), @@ -64,39 +247,51 @@ impl Dialect for Cursor { let resumed = opts .resume .as_ref() - .and_then(|cursor| cursor.str_field(&["session_id"])); + .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 established = match resumed { - Some(session_id) => setup - .load( - acp::LoadSessionRequest::new(session_id.to_string(), opts.cwd.clone()) - .mcp_servers(mcp_servers), + 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, ) - .await - .map(|_| ()), - None => setup - .connection - .send_request( - acp::NewSessionRequest::new(opts.cwd.clone()).mcp_servers(mcp_servers), - ) - .block_task() - .await - .map(|_| ()), + } + 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) + } }; - match established { - Err(err) if is_auth_required(&err) => Err(AgentError::Provider(format!( - "Cursor is not signed in: {}. Run `cursor-agent login`, then start the session again.", - describe(&err) - ))), - Err(err) => Err(AgentError::Protocol(format!( - "Cursor could not start a session: {}", - describe(&err) - ))), - Ok(()) => Err(AgentError::Provider( - "Cursor sessions are not available yet in this build of Tcode.".into(), - )), + setup.adopt(modes.as_ref(), config_options.as_deref()); + if let Some(model) = &opts.model { + select_model(setup, &session_id, model, config_options.as_deref()).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( @@ -109,7 +304,11 @@ impl Dialect for Cursor { if state.loading() { return Vec::new(); } - state.apply_update(notification.update) + 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( @@ -117,7 +316,11 @@ impl Dialect for Cursor { _state: &mut State, result: Result, ) -> TurnEnd { - let (status, message) = prompt_status(&result); + 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, @@ -135,5 +338,292 @@ impl Dialect for Cursor { ) { } - async fn set_approval_mode(&self, _session: &Session, _mode: ApprovalMode) {} + 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) + } +} + +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. 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: Option<&[acp::SessionConfigOption]>, +) -> Result<(), AgentError> { + let current = config_options + .into_iter() + .flatten() + .find(|option| option.id.0.as_ref() == MODEL_CONFIG_ID) + .and_then(|option| match &option.kind { + acp::SessionConfigKind::Select(select) => Some(select.current_value.0.to_string()), + _ => None, + }); + if current.as_deref() == Some(model) { + return Ok(()); + } + let selected = setup + .connection + .send_request(acp::SetSessionConfigOptionRequest::new( + session_id.clone(), + acp::SessionConfigId::new(MODEL_CONFIG_ID), + acp::SessionConfigOptionValue::value_id(acp::SessionConfigValueId::new(model)), + )) + .block_task() + .await + .map_err(|err| { + AgentError::Provider(format!( + "Cursor did not accept the model `{model}`: {}", + describe(&err) + )) + })?; + setup.adopt(None, Some(&selected.config_options)); + Ok(()) +} + +/// 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..4a8c78814 --- /dev/null +++ b/crates/agent/src/cursor/tests.rs @@ -0,0 +1,855 @@ +//! 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, ApprovalMode, InteractionMode, ItemContent, ItemStatus, LaunchEnv, 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_is_selected_and_its_parameters_replace_the_last_models() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.model = Some("gpt-5".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; + 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 ids: Vec<_> = selections + .iter() + .map(|selection| selection.id.as_str()) + .collect(); + assert!(ids.contains(&"acp:cfg:reasoning"), "{ids:?}"); + assert!( + !ids.contains(&"acp:cfg:fast"), + "the previous model's parameter is gone: {ids:?}" + ); + 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 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/lib.rs b/crates/agent/src/lib.rs index b3617e65c..4a0bb7830 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -702,7 +702,7 @@ impl UserInputDelivery { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct UserInputQuestion { /// The answer key. Claude and Grok: the complete question text (both index - /// answers by question text). Codex: the native question id. + /// answers by question text). Codex and Cursor: the native question id. pub id: String, pub header: String, pub question: String, @@ -818,6 +818,8 @@ pub async fn list_models( /// - 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. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ApprovalMode { diff --git a/crates/agent/tests/fixtures/cursor/PROVENANCE.md b/crates/agent/tests/fixtures/cursor/PROVENANCE.md new file mode 100644 index 000000000..9e021d632 --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/PROVENANCE.md @@ -0,0 +1,81 @@ +# 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` — `session/set_config_option` answers with the full + refreshed `configOptions` (1412-1446); a new model brings its own + parameters (1705-1731). +- `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..5c6afbf2a --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/source_derived.json @@ -0,0 +1,488 @@ +{ + "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" } + ] + } + ] + }, + "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" +} From def92aa98ea6f99d7e3ca9bb5bfae9bc423df32a Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 15:37:58 +0800 Subject: [PATCH 12/19] feat(services): probe Cursor's sign-in from `cursor-agent status` `cursor-agent status --format json` reports the stored login. Signed in, the provider is ready with the account's email. It says `unauthenticated` even while CURSOR_API_KEY or CURSOR_AUTH_TOKEN signs the CLI in (observed with 2026.10.01-e373342), so it counts as signed out only when the profile's environment and the inherited one set neither; otherwise the state stays indeterminate. A key passed as a launch argument is not visible to the probe. --- crates/services/src/provider_probe.rs | 87 ++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/crates/services/src/provider_probe.rs b/crates/services/src/provider_probe.rs index 814368537..52f2bc0fa 100644 --- a/crates/services/src/provider_probe.rs +++ b/crates/services/src/provider_probe.rs @@ -124,19 +124,57 @@ 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) } + 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. - // `cursor-agent status` ignores CURSOR_API_KEY, so it cannot tell a - // signed-out Cursor from one using an API key. - ProviderKind::Cursor | ProviderKind::Acp => None, + 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. @@ -204,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( From 8cc54f635fc7aa5601f481be1dfa151b4ea5fd15 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 15:41:46 +0800 Subject: [PATCH 13/19] feat(cursor): approve by kind, own the model and restore parameters Built on the shared ACP dialect hooks: - Approvals (`auto_approves`): under ReadOnly tcode grants Cursor's read and search permission requests, and under AutoAcceptEdits its file-change requests, each with the request's allow-once option; everything else is asked, and nothing automatic ever picks Cursor's allow-always, which writes its permanent allowlist. A web fetch is asked about even under ReadOnly: it can carry data out. The kinds are Cursor's own (3351.index.js 2684-2753, 857-887). - The model (`owned_config_options`): the composer's model picker owns Cursor's `model` option, so it is no longer surfaced as a second selector; `SessionStarted` still reports it. Its parameters stay provider options. - Parameters (`config_selection`): a new process (resume, model change, approval-mode change) re-selects the model parameters the session chose if the model still offers them, since Cursor starts on the ones it last saved for the model, which another session may have changed. - Client services (`client_services`): not offered. Cursor's ACP server reads `clientCapabilities` only for `_meta.parameterizedModelPicker` and subagents (1148, 1496, 3410) and runs its file and shell tools itself; declining keeps that so. --- crates/agent/src/cursor.rs | 139 ++++++++++++++---- crates/agent/src/cursor/tests.rs | 119 +++++++++++++-- crates/agent/src/lib.rs | 3 +- .../agent/tests/fixtures/cursor/PROVENANCE.md | 7 +- .../tests/fixtures/cursor/source_derived.json | 41 ++++++ 5 files changed, 271 insertions(+), 38 deletions(-) diff --git a/crates/agent/src/cursor.rs b/crates/agent/src/cursor.rs index 681b09b64..fc5db88f4 100644 --- a/crates/agent/src/cursor.rs +++ b/crates/agent/src/cursor.rs @@ -140,6 +140,7 @@ fn signed_out() -> AgentError { } struct Cursor { + approval_mode: ApprovalMode, updates: Mutex, /// Cursor's todo list as `cursor/update_todos` last left it. todos: Mutex>, @@ -150,6 +151,7 @@ struct Cursor { 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), @@ -280,9 +282,13 @@ impl Dialect for Cursor { } }; setup.adopt(modes.as_ref(), config_options.as_deref()); - if let Some(model) = &opts.model { - select_model(setup, &session_id, model, config_options.as_deref()).await?; + 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) @@ -429,6 +435,32 @@ impl Dialect for Cursor { } 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 { @@ -439,34 +471,23 @@ fn start_failure(err: &acp::Error, context: &str) -> AgentError { } } -/// Put the session on the composer's model. Cursor resumes a loaded -/// conversation on the model it last used, so this follows `session/load` -/// as well as `session/new`. +/// 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: Option<&[acp::SessionConfigOption]>, -) -> Result<(), AgentError> { + config_options: &[acp::SessionConfigOption], +) -> Result>, AgentError> { let current = config_options - .into_iter() - .flatten() + .iter() .find(|option| option.id.0.as_ref() == MODEL_CONFIG_ID) - .and_then(|option| match &option.kind { - acp::SessionConfigKind::Select(select) => Some(select.current_value.0.to_string()), - _ => None, - }); - if current.as_deref() == Some(model) { - return Ok(()); + .and_then(select_value); + if current == Some(model) { + return Ok(None); } - let selected = setup - .connection - .send_request(acp::SetSessionConfigOptionRequest::new( - session_id.clone(), - acp::SessionConfigId::new(MODEL_CONFIG_ID), - acp::SessionConfigOptionValue::value_id(acp::SessionConfigValueId::new(model)), - )) - .block_task() + let selected = set_config_option(setup, session_id, MODEL_CONFIG_ID, model) .await .map_err(|err| { AgentError::Provider(format!( @@ -474,8 +495,76 @@ async fn select_model( describe(&err) )) })?; - setup.adopt(None, Some(&selected.config_options)); - Ok(()) + 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 diff --git a/crates/agent/src/cursor/tests.rs b/crates/agent/src/cursor/tests.rs index 4a8c78814..7cd093e78 100644 --- a/crates/agent/src/cursor/tests.rs +++ b/crates/agent/src/cursor/tests.rs @@ -12,8 +12,9 @@ use smol::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufR use smol::net::{TcpListener, TcpStream}; use crate::{ - AgentEvent, ApprovalMode, InteractionMode, ItemContent, ItemStatus, LaunchEnv, PlanStepStatus, - ResumeCursor, SessionCommand, SessionHandle, SessionOptions, ThreadItem, TurnStatus, + 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"); @@ -374,11 +375,17 @@ fn catalog_ids_are_the_model_values_cursor_accepts() { } #[test] -fn the_composers_model_is_selected_and_its_parameters_replace_the_last_models() { +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; @@ -388,6 +395,13 @@ fn the_composers_model_is_selected_and_its_parameters_replace_the_last_models() 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 { .. }) @@ -400,14 +414,14 @@ fn the_composers_model_is_selected_and_its_parameters_replace_the_last_models() let Some(AgentEvent::ProviderOptions { selections, .. }) = events.last() else { unreachable!(); }; - let ids: Vec<_> = selections + let selections: Vec<_> = selections .iter() - .map(|selection| selection.id.as_str()) + .map(|selection| (selection.id.as_str(), selection.value.as_str().unwrap())) .collect(); - assert!(ids.contains(&"acp:cfg:reasoning"), "{ids:?}"); - assert!( - !ids.contains(&"acp:cfg:fast"), - "the previous model's parameter is gone: {ids:?}" + 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; @@ -677,6 +691,93 @@ fn an_interrupt_cancels_a_pending_question() { })); } +#[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 { diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 4a0bb7830..b3447e850 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -819,7 +819,8 @@ pub async fn list_models( /// - 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. +/// 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 { diff --git a/crates/agent/tests/fixtures/cursor/PROVENANCE.md b/crates/agent/tests/fixtures/cursor/PROVENANCE.md index 9e021d632..d1d261165 100644 --- a/crates/agent/tests/fixtures/cursor/PROVENANCE.md +++ b/crates/agent/tests/fixtures/cursor/PROVENANCE.md @@ -39,9 +39,10 @@ backend. (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` — `session/set_config_option` answers with the full - refreshed `configOptions` (1412-1446); a new model brings its own - parameters (1705-1731). +- `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 diff --git a/crates/agent/tests/fixtures/cursor/source_derived.json b/crates/agent/tests/fixtures/cursor/source_derived.json index 5c6afbf2a..68e6be11f 100644 --- a/crates/agent/tests/fixtures/cursor/source_derived.json +++ b/crates/agent/tests/fixtures/cursor/source_derived.json @@ -96,6 +96,47 @@ } ] }, + "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": [ { From d1d7b979799c4af86543127d3eaf55427f5b36aa Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 16:02:14 +0800 Subject: [PATCH 14/19] feat(probe): stay open for turns the agent starts itself `--linger ` keeps the session after a turn completes until no turn has run for that long, then shuts down and waits for the close. `--follow-up ` sends another turn that long after the first completes, whether or not a turn is running then. Together they show a turn an agent starts on its own and a turn sent during one. --- crates/agent/examples/probe.rs | 94 +++++++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/crates/agent/examples/probe.rs b/crates/agent/examples/probe.rs index 6b6af3545..2c18052d3 100644 --- a/crates/agent/examples/probe.rs +++ b/crates/agent/examples/probe.rs @@ -5,11 +5,14 @@ //! 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), `--interrupt-after `, `--steer `, -//! and `--image `. Only one of the last three may be used. +//! 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, @@ -126,6 +129,7 @@ fn main() { 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(); @@ -156,6 +160,16 @@ fn main() { } "--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 { @@ -255,6 +269,7 @@ fn main() { probe_mode, acp, mcp_servers, + continuation, Resumption { resume, fork, @@ -272,6 +287,12 @@ struct Resumption { 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, @@ -283,6 +304,7 @@ async fn run_probe( probe_mode: ProbeMode, acp: Option, mcp_servers: Vec, + continuation: Continuation, resumption: Resumption, binary_path: Option, model: Option, @@ -385,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 { @@ -510,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(); } } From db8faf2aaaf3948fe100ddbcf7e1789e133d517b Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 16:02:14 +0800 Subject: [PATCH 15/19] feat(grok): run the turns Grok starts itself as turns When a background command finishes, Grok runs a prompt of its own (`task-completed-`) reporting it. No `session/prompt` answers it, so its events arrived with no turn around them. It now opens and completes like any turn, following the contract the other providers already use for turns the user did not send: Claude Code synthesizes TurnStarted for its self-invoked turn and its `result` completes it; the runtime counts any TurnStarted as a running turn, queues or steers sends meanwhile, and dispatches the queue on TurnCompleted. Shared session (acp_session): - State::begin_agent_turn opens a turn the agent started; while another turn is still open it opens as soon as that one completes. - State::end_turn completes a turn from the agent's own signal if it is still the open one. A sent turn's `session/prompt` result then completes nothing; finish_turn only completes the turn it names while that turn is open, and the turn id now travels with the result instead of a loop-local slot (a send overwriting that slot used to complete the wrong turn and then hit its `expect`). - Interrupt acts on whatever turn is open, including an agent-started one. - A turn the client sent while an agent-started turn ran takes its place, as a send during Claude's self-invoked turn does: one completion, and the agent turn's streamed text is closed when it ends. Grok: - `_x.ai/queue/changed` reports each running prompt; one the client did not queue opens an agent turn under Grok's prompt id. - `turn_completed` ends the turn of the prompt it names, in order with everything else: when a task finishes during a sent turn, Grok starts its own prompt before the sent turn's `session/prompt` result arrives. The status comes from its stop reason (error text from `agent_result`), and the usage is the turn's total on top of the last model call's. The `session/prompt` result remains the fallback for a prompt without one. - A drop to no background tasks is withheld while a sent turn is open, as Claude withholds it until its self-invoked turn, so the runtime does not take the process for idle before Grok's own turn. - A backgrounded command's card stays completed: Grok streams its later output as running updates and never completes it again. Verified against grok 1.0.46 with a scripted model backend through the probe: a task finishing after a turn, during a turn, an interrupt during Grok's turn, and a send during it. agent_started_turn.jsonl records the during-a-turn case followed by another sent turn; its replay fails without the agent turn, with a second completion, with the count not withheld, or with the background card reopened. --- crates/agent/src/acp_session.rs | 104 +++-- crates/agent/src/grok.rs | 421 +++++++++++++++--- crates/agent/tests/fixtures/grok/README.md | 32 +- .../fixtures/grok/agent_started_turn.jsonl | 89 ++++ 4 files changed, 548 insertions(+), 98 deletions(-) create mode 100644 crates/agent/tests/fixtures/grok/agent_started_turn.jsonl diff --git a/crates/agent/src/acp_session.rs b/crates/agent/src/acp_session.rs index 31c585d15..3ef715a7d 100644 --- a/crates/agent/src/acp_session.rs +++ b/crates/agent/src/acp_session.rs @@ -845,7 +845,6 @@ async fn connected_actor( let session = client.session(connection.clone()); let (turn_tx, turn_done) = smol::channel::unbounded::(); - let mut turn_id: Option = None; let mut turn_seq: u64 = 0; let close_reason = loop { @@ -881,18 +880,12 @@ async fn connected_actor( &session, &session_id, &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(dialect, state, events, &id, outcome).await; - } + Input::Turn(outcome) => finish_turn(dialect, state, events, outcome).await, Input::Io(reason) => break Some(reason), } }; @@ -914,6 +907,7 @@ async fn connected_actor( } struct TurnOutcome { + turn_id: String, result: Result, } @@ -1005,7 +999,6 @@ async fn handle_command( session: &Session, session_id: &acp::SessionId, turn_tx: &Sender, - turn_id: &mut Option, turn_seq: &mut u64, pending_deliveries: &Arc>>, ) { @@ -1026,7 +1019,6 @@ async fn handle_command( } => { *turn_seq += 1; let id = format!("turn-{turn_seq}"); - *turn_id = Some(id.clone()); state.lock_recover().turn = Some(id.clone()); let request = @@ -1044,12 +1036,18 @@ async fn handle_command( executor .spawn(async move { let result = request.block_task().await; - let _ = turn_tx.send(TurnOutcome { result }).await; + let _ = turn_tx + .send(TurnOutcome { + turn_id: id, + result, + }) + .await; }) .detach(); } SessionCommand::Interrupt => { - if turn_id.is_none() { + // 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 @@ -1190,39 +1188,25 @@ async fn cancel_pending(state: &Arc>, events: &Sender) } } +/// 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, - turn_id: &str, outcome: TurnOutcome, ) { - let end = { + let completion = { let mut state = state.lock_recover(); - let tail = state.flush_text(); - state.turn = None; + if state.turn.as_deref() != Some(outcome.turn_id.as_str()) { + return; + } let end = dialect.turn_end(&mut state, outcome.result); - (tail, end) + state.complete_turn(&outcome.turn_id, end) }; - let (tail, end) = end; - for event in tail { + for event in completion { let _ = events.send(event).await; } - if let Some(message) = end.message { - let _ = events - .send(AgentEvent::Error { - message, - fatal: false, - }) - .await; - } - let _ = events - .send(AgentEvent::TurnCompleted { - turn_id: turn_id.to_string(), - status: end.status, - usage: end.usage, - }) - .await; } /// The status and message of a `session/prompt` result. A `-32800 @@ -1806,6 +1790,8 @@ pub(crate) struct State { 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, } @@ -1829,6 +1815,7 @@ impl State { usage: None, options: OptionRegistry::default(), owned_options: &[], + next_turn: None, modes: None, previous_non_plan_mode: None, } @@ -1849,6 +1836,55 @@ impl State { 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; diff --git a/crates/agent/src/grok.rs b/crates/agent/src/grok.rs index a16bf021b..b4db05dcf 100644 --- a/crates/agent/src/grok.rs +++ b/crates/agent/src/grok.rs @@ -4,7 +4,7 @@ //! The protocol machinery is [`crate::acp_session`]; this module is Grok's //! dialect. -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; use std::sync::{Mutex, MutexGuard}; @@ -13,7 +13,7 @@ use serde_json::{Map, Value, json}; use crate::acp_session::{ self, Dialect, Established, Launch, Session, Setup, State, TurnEnd, describe, is_auth_required, - prompt_status, + prompt_status, stop_reason_status, }; use crate::{ AgentError, AgentEvent, ApprovalMode, Attachment, Compaction, InteractionMode, ItemContent, @@ -30,6 +30,8 @@ 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"; @@ -118,6 +120,35 @@ struct Grok { /// 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. @@ -127,6 +158,8 @@ struct Models { windows: HashMap, /// The model serving the session. current: Option, + /// The latest model call's usage. + last_call: Option, } impl Grok { @@ -136,6 +169,12 @@ impl Grok { .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() @@ -149,8 +188,8 @@ impl Grok { let prompt = count("input_tokens") + count("cache_read_input_tokens") + count("cache_creation_input_tokens"); - let models = self.models(); - TokenUsage { + 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")), @@ -158,7 +197,101 @@ impl Grok { 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 @@ -185,7 +318,7 @@ impl Grok { } /// One `_x.ai/session_notification` update. - fn session_notification(&self, update: &Value) -> Vec { + 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 @@ -224,8 +357,16 @@ impl Grok { .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()), @@ -469,7 +610,18 @@ impl Dialect for Grok { } let mut update = notification.update; let shell = match &mut update { - acp::SessionUpdate::ToolCallUpdate(update) => prepare_tool_update(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); @@ -589,15 +741,31 @@ impl Dialect for Grok { }) } - fn notification(&self, _state: &mut State, method: &str, params: Value) -> Vec { - match method { + 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(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 } } @@ -814,20 +982,24 @@ mod tests { McpRegistration, OptionSelection, SessionCommand, ThreadItem, }; - /// A resumed Grok 1.0.46 session under AutoAcceptEdits, recorded on the - /// wire in both directions (provenance: `tests/fixtures/grok/README.md`). + // 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"; - const TEST_NAME: &str = "grok::tests::recorded_resumed_turn_maps_to_the_canonical_stream"; /// 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() { + 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 RESUMED_TURN.lines() { + for record in fixture.lines() { let record: Value = serde_json::from_str(record).unwrap(); let recorded = &record["message"]; if record["from"] == "agent" { @@ -865,16 +1037,17 @@ mod tests { for _ in client {} } - /// A stand-in `grok` that runs this test as [`replay_agent`]; whatever - /// arguments Tcode launches Grok with are left unread. - fn stand_in_binary(dir: &std::path::Path) -> PathBuf { + /// 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_NAME} --nocapture\n"); + 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 @@ -882,11 +1055,7 @@ mod tests { #[cfg(windows)] { let path = dir.join("grok.cmd"); - std::fs::write( - &path, - format!("@\"{exe}\" --exact {TEST_NAME} --nocapture\r\n"), - ) - .unwrap(); + std::fs::write(&path, format!("@\"{exe}\" --exact {test} --nocapture\r\n")).unwrap(); path } } @@ -924,6 +1093,19 @@ mod tests { .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() @@ -934,54 +1116,54 @@ mod tests { .collect() } - #[test] - fn recorded_resumed_turn_maps_to_the_canonical_stream() { - if std::env::var_os(REPLAY_AGENT).is_some() { - return replay_agent(); - } - let dir = std::env::temp_dir().join(format!("tcode-grok-replay-{}", std::process::id())); + /// 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(); - let opts = SessionOptions { - cwd: dir.clone(), + SessionOptions { + binary_path: Some(stand_in_binary(&dir, test)), + cwd: dir, model: None, - resume: Some(ResumeCursor( - json!({ "session_id": "01a10095-e65c-7983-a817-6884b010f1ae" }), - )), + resume: None, fork: false, - binary_path: Some(stand_in_binary(&dir)), - approval_mode: ApprovalMode::AutoAcceptEdits, - option_selections: vec![OptionSelection { - id: "acp:cfg:reasoning_effort".into(), - value: json!("low"), - }], + approval_mode: ApprovalMode::FullAccess, + option_selections: Vec::new(), interaction_mode: InteractionMode::Build, - mcp_servers: vec![McpRegistration { - name: "tcode_probe".into(), - url: "http://127.0.0.1:18433/mcp".into(), - bearer_token: "tcode-secret".into(), - }], + 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(); - handle - .commands - .send(SessionCommand::SendTurn { - delivery_id: 7, - text: "Count, edit, ask and echo".into(), - options: None, - attachments: Vec::new(), - }) - .await - .unwrap(); - let mut events = Vec::new(); + 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) => { @@ -1003,7 +1185,14 @@ mod tests { }) .collect(), }), - AgentEvent::TurnCompleted { .. } => Some(SessionCommand::Shutdown), + 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 { .. }); @@ -1023,7 +1212,6 @@ mod tests { }, )); 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!( @@ -1033,6 +1221,32 @@ mod tests { ), "{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 { .. })) @@ -1124,4 +1338,93 @@ mod tests { 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/tests/fixtures/grok/README.md b/crates/agent/tests/fixtures/grok/README.md index 9a8bdedad..9d9ea7192 100644 --- a/crates/agent/tests/fixtures/grok/README.md +++ b/crates/agent/tests/fixtures/grok/README.md @@ -1,10 +1,12 @@ -# Grok session fixture +# Grok session fixtures -`resumed_turn.jsonl` is one Grok Build session recorded on the wire in both +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": }`. -## Provenance +## `resumed_turn.jsonl` + +### Provenance - CLI: `grok 1.0.46 (2765805b9442)`, darwin-arm64, in a throwaway `HOME` and `GROK_HOME`. @@ -24,7 +26,7 @@ directions, in wire order, untruncated: one JSON object per line, with a pass-through recorder as `grok` on `PATH`. Grok was launched as `grok --permission-mode default agent --reasoning-effort low stdio`. -## Scenario +### 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`) @@ -44,10 +46,30 @@ and runs one turn whose scripted model calls are: The probe then shuts the session down (`session/close`). -## Sanitization +### 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"}}}} From be35d5818273451ad5035e29e4307dcdd5fda0ab Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 16:11:27 +0800 Subject: [PATCH 16/19] docs(agent): keep the ACP comments to what the code cannot express The comments the shared session carries over lose their history: the npm launch recipe is described as the registry's contract; the config-option ids are explained by what reads them rather than by the protocol revision that introduced categories; the EOF note names the SDK behaviour it works around without a stale version; and the approval mapping and update mapping docs lose their history and test notes. --- crates/agent/src/acp.rs | 2 +- crates/agent/src/acp_session.rs | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/agent/src/acp.rs b/crates/agent/src/acp.rs index ba7d5db59..e828b1a77 100644 --- a/crates/agent/src/acp.rs +++ b/crates/agent/src/acp.rs @@ -208,7 +208,7 @@ impl Dialect for Registry { /// 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. +/// contract); `Binary` / `Custom` run as given. fn launch_command(launch: &AcpLaunch) -> Result<(PathBuf, Vec), AgentError> { match launch { AcpLaunch::Npx { package, args, .. } => { diff --git a/crates/agent/src/acp_session.rs b/crates/agent/src/acp_session.rs index 3ef715a7d..a0569ec3b 100644 --- a/crates/agent/src/acp_session.rs +++ b/crates/agent/src/acp_session.rs @@ -1223,9 +1223,9 @@ pub(crate) fn prompt_status( } /// 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. +/// 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, @@ -1488,8 +1488,8 @@ fn missing_mode_warning(mode: InteractionMode) -> AgentEvent { } } -/// Map our four fixed decisions onto the agent's own permission options (so the -/// existing approval UI keeps working), and pass an offered `Option(id)` through. +/// 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 @@ -1641,9 +1641,9 @@ impl OptionRegistry { }); } 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. + // 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(), @@ -1979,9 +1979,8 @@ impl State { 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. + /// 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; From e4a13906318ae29b468058830fde387f016ae47a Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 16:11:37 +0800 Subject: [PATCH 17/19] docs: name Cursor where the README and CONTRIBUTING list the agents Cursor sessions run now, so the getting-started step lists `cursor-agent` among the CLIs to have on PATH, and a bug report names any of the six native agents rather than only Claude Code and Codex. --- CONTRIBUTING.md | 9 +++++---- README.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca2489a6d..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 diff --git a/README.md b/README.md index dc193aab9..84c71c142 100644 --- a/README.md +++ b/README.md @@ -181,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`, `opencode` or `grok` 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. From 970953039b5979c39c144845a83ed75aeb3273be Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 16:12:36 +0800 Subject: [PATCH 18/19] docs(agent): describe ACP option lists for every agent that speaks ACP Cursor and Grok are native providers that speak ACP, so approval option lists and `SetOption` routing are not the registry agent's alone; options are set through `session/set_mode` or `session/set_config_option`, never `session/set_model`. --- crates/agent/src/lib.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index b3447e850..9d4d68170 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -924,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, @@ -1491,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, } @@ -1554,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), } From f6767a59a3bc27ac6a457c39e63a9ff108063f10 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 16:21:59 +0800 Subject: [PATCH 19/19] fix(ui): show the native-approvals switch only for providers it governs The provider dialog pushed pi's "Native approvals" switch inside the home-path block, so every provider with a home override (Claude, Codex, Cursor, Grok) showed a switch whose help text describes pi and whose setting nothing reads for them. Its only consumers, the runtime's launch approval mode and the composer's approval-mode availability, read it behind `downgrade_approval_without_native_approvals`; the switch is now gated on that capability, and the home field stays under `home_path`. --- crates/ui/src/provider_dialog.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui/src/provider_dialog.rs b/crates/ui/src/provider_dialog.rs index 18eafe345..8cd5125a5 100644 --- a/crates/ui/src/provider_dialog.rs +++ b/crates/ui/src/provider_dialog.rs @@ -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")