diff --git a/src/providers/cursor/client.rs b/src/providers/cursor/client.rs index 79d784fd..1ffd2ac7 100644 --- a/src/providers/cursor/client.rs +++ b/src/providers/cursor/client.rs @@ -29,6 +29,80 @@ impl CursorUpstreamResponse { } } +/// How long the reader waits with nothing on the wire before it gives up. +/// +/// The agent goes quiet for minutes while Claude Code runs the tool it asked +/// for, so this only has to catch a dead connection. +const RUN_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +/// One event from a live run. +pub enum LiveEvent { + Message(Box), + End, + Error(String), +} + +/// An open `AgentService/Run` stream. +/// +/// `Run` is bidirectional: the request body stays open for the whole turn and +/// every `ExecServerMessage` is answered on the same stream. Closing it (the +/// old code aborted the body after the first pause) makes the agent re-plan +/// from scratch, which re-issues the tool call it was already waiting on. +pub struct LiveRun { + tx: mpsc::Sender>, + pub events: mpsc::Receiver, + writer: tokio::task::JoinHandle<()>, +} + +impl LiveRun { + /// Write one client message on the open request body. + pub async fn send(&self, message: &proto::AgentClientMessage) -> Result<(), CursorError> { + self.tx + .send(Ok(encode_connect_frame(message.encode_to_vec(), 0))) + .await + .map_err(|_| CursorError::internal("Cursor run already closed")) + } +} + +impl Drop for LiveRun { + fn drop(&mut self) { + // Without this the heartbeat task holds the body open forever and the + // abandoned run keeps a connection (and its tokens) alive upstream. + self.writer.abort(); + } +} + +#[cfg(test)] +impl LiveRun { + /// A run backed by an in-memory channel: registry tests never touch a + /// transport. + pub(crate) fn for_test(events: mpsc::Receiver) -> Self { + let (tx, _rx) = mpsc::channel(1); + Self { + tx, + events, + writer: tokio::spawn(async {}), + } + } + + /// A run whose writes land in `tx`, for tests that inspect the frames. + pub(crate) fn from_channel_for_test(tx: mpsc::Sender>) -> Self { + let (_event_tx, events) = mpsc::channel(1); + Self { + tx, + events, + writer: tokio::spawn(async {}), + } + } +} + +/// The open HTTP/2 exchange, before either side is turned into events. +struct RunTransport { + response: reqwest::Response, + tx: mpsc::Sender>, + writer: tokio::task::JoinHandle<()>, +} + /// HTTP/2 client for the Cursor AgentService/Run endpoint. pub struct CursorHttpClient { client: reqwest::Client, @@ -43,44 +117,178 @@ impl Default for CursorHttpClient { impl CursorHttpClient { pub fn new() -> Self { - // Use HTTP/2 prior knowledge for cleartext URLs (mock testing) and - // standard TLS for https URLs. + // One pooled client per transport kind, shared by every request: a client + // built per request opened a fresh TCP + TLS + HTTP/2 connection each + // time and never reused one. + static TLS: std::sync::OnceLock = std::sync::OnceLock::new(); + static CLEARTEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + let base_url = config::cursor_base_url(); + // HTTP/2 prior knowledge for cleartext URLs (mock testing), standard + // TLS for https URLs. let is_cleartext = base_url.starts_with("http://"); + let slot = if is_cleartext { &CLEARTEXT } else { &TLS }; + let client = slot + .get_or_init(|| { + let mut builder = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .tcp_keepalive(std::time::Duration::from_secs(30)) + // hyper only sends HTTP/2 pings when an interval is set; + // the timeout and while_idle knobs alone are inert. + .http2_keep_alive_interval(std::time::Duration::from_secs(20)) + .http2_keep_alive_timeout(std::time::Duration::from_secs(30)) + .http2_keep_alive_while_idle(true); + if is_cleartext { + builder = builder.http2_prior_knowledge(); + } + builder.build().expect("CursorHttpClient: reqwest client") + }) + .clone(); + + Self { client, base_url } + } - let mut builder = reqwest::Client::builder() - .http2_keep_alive_timeout(std::time::Duration::from_secs(30)) - .http2_keep_alive_while_idle(true); + /// Open a live `AgentService/Run` and keep it open. + /// + /// The returned handle owns the write side of the request body, so the + /// caller can answer exec requests on the same stream, and a reader task + /// that decodes response frames incrementally into `LiveRun::events`. + pub async fn start_run( + &self, + token: &str, + prompt: &str, + model: &str, + images: &[CursorSelectedImage], + ) -> Result { + let transport = self.open_run(token, prompt, model, images).await?; + let status = transport.response.status().as_u16(); + let headers = transport.response.headers().clone(); - if is_cleartext { - builder = builder.http2_prior_knowledge(); + if status >= 400 { + let error_detail = headers + .get("grpc-message") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let mut stream = transport.response.bytes_stream(); + let mut body = Vec::new(); + while let Some(Ok(chunk)) = stream.next().await { + body.extend_from_slice(&chunk); + } + transport.writer.abort(); + let detail = parse_error_body(&body, &headers).or(error_detail); + return Err(CursorError::new(status, "Cursor upstream error", detail)); } - let client = builder.build().expect("CursorHttpClient: reqwest client"); + let (event_tx, event_rx) = mpsc::channel::(64); + let kv_tx = transport.tx.clone(); + tokio::spawn(async move { + let mut stream = transport.response.bytes_stream(); + let mut decoder = ConnectFrameDecoder::new(); + let mut blobs: std::collections::HashMap, Vec> = + std::collections::HashMap::new(); + loop { + let next = tokio::time::timeout(RUN_IDLE_TIMEOUT, stream.next()).await; + let chunk = match next { + Ok(Some(Ok(chunk))) => chunk, + Ok(Some(Err(error))) => { + let _ = event_tx + .send(LiveEvent::Error(format!("read body: {error}"))) + .await; + return; + } + Ok(None) => { + let _ = event_tx.send(LiveEvent::End).await; + return; + } + Err(_) => { + let _ = event_tx + .send(LiveEvent::Error( + "Cursor upstream went quiet with the run open".to_string(), + )) + .await; + return; + } + }; + + let frames = match decoder.push(&chunk) { + Ok(frames) => frames, + Err(error) => { + let _ = event_tx + .send(LiveEvent::Error(format!("frame decode: {error}"))) + .await; + return; + } + }; - Self { client, base_url } + for frame in frames { + if frame.flags & FLAG_END != 0 { + // The upstream closed the turn: a payload carries a + // Connect error, an empty one is a clean end. + let event = match parse_connect_error(&frame.payload) { + Some(error) => LiveEvent::Error(error.to_string()), + None => LiveEvent::End, + }; + let _ = event_tx.send(event).await; + return; + } + match decode_frame_payload(&frame) { + Ok(message) => { + // The agent store is answered here rather than by + // the pump: a blob the server is waiting on must + // land even while the turn is paused on a tool. + if let Some(kv) = message.kv_server_message.as_ref() + && let Some(reply) = kv_reply(kv, &mut blobs) + { + if kv_tx + .send(Ok(encode_connect_frame(reply.encode_to_vec(), 0))) + .await + .is_err() + { + return; + } + continue; + } + if event_tx + .send(LiveEvent::Message(Box::new(message))) + .await + .is_err() + { + return; + } + } + // Keep reading: one frame the bridge cannot name must + // not end a turn that is still running. + Err(_) => continue, + } + } + } + }); + + Ok(LiveRun { + tx: transport.tx, + events: event_rx, + writer: transport.writer, + }) } - /// Run the Cursor agent with the given prompt and token. - /// - /// Opens a bidirectional Connect stream, sends the agent request frames, - /// and keeps the request body alive while collecting the response frames. - pub async fn run_agent( + /// Send the run request frames and keep the body open with heartbeats. + async fn open_run( &self, token: &str, prompt: &str, model: &str, images: &[CursorSelectedImage], - ) -> Result { + ) -> Result { let resolved = super::model::resolve_cursor_model(model) .map_err(|e| CursorError::internal(format!("model resolution: {e}")))?; let request_id = uuid::Uuid::new_v4().to_string(); let frames = build_run_frames(prompt, &resolved, images, &request_id); let (tx, rx) = mpsc::channel::>(8); - let sender = tokio::spawn(async move { + let writer_tx = tx.clone(); + let writer = tokio::spawn(async move { for (index, frame) in frames.into_iter().enumerate() { - if tx.send(Ok(frame)).await.is_err() { + if writer_tx.send(Ok(frame)).await.is_err() { return; } let delay = match index { @@ -95,7 +303,7 @@ impl CursorHttpClient { heartbeat.tick().await; loop { heartbeat.tick().await; - if tx.send(Ok(heartbeat_frame())).await.is_err() { + if writer_tx.send(Ok(heartbeat_frame())).await.is_err() { return; } } @@ -131,20 +339,45 @@ impl CursorHttpClient { .await .map_err(CursorError::from_reqwest)?; - let status = response.status().as_u16(); - let headers = response.headers().clone(); - let error_detail = response - .headers() + Ok(RunTransport { + response, + tx, + writer, + }) + } + + /// Run the Cursor agent to the end of its turn and return the raw body. + /// + /// Used where the caller needs the untouched frame bytes (traffic capture, + /// non-streaming replies). Tool rounds need [`Self::start_run`] instead. + pub async fn run_agent( + &self, + token: &str, + prompt: &str, + model: &str, + images: &[CursorSelectedImage], + ) -> Result { + let transport = self.open_run(token, prompt, model, images).await?; + let sender = transport.writer; + let status = transport.response.status().as_u16(); + let headers = transport.response.headers().clone(); + let error_detail = headers .get("grpc-message") .and_then(|value| value.to_str().ok()) .map(str::to_string); - let mut stream = response.bytes_stream(); + let mut stream = transport.response.bytes_stream(); let mut body_bytes = Vec::new(); let mut received_data = false; loop { + // The upstream stays silent while the model thinks — measured + // 12/10/2026: a large prompt sent its checkpoint frames, went quiet + // for more than five seconds, and the old 5s idle cut returned a + // partial body with no model text (Claude Code showed an empty + // reply). The stream ends with an explicit END frame, so the idle + // timeout only guards a truly dead connection. let timeout = if received_data { - std::time::Duration::from_secs(5) + std::time::Duration::from_secs(8) } else { std::time::Duration::from_secs(60) }; @@ -212,11 +445,23 @@ fn field_varint(field: u64, value: u64) -> Vec { out } +fn model_parameter(id: &str, value: &str) -> Vec { + let mut parameter = field_string(1, id); + parameter.extend(field_string(2, value)); + parameter +} + fn model_message(model: &str, fast: bool) -> Vec { let mut out = field_string(1, model); - let mut parameter = field_string(1, "fast"); - parameter.extend(field_string(2, if fast { "true" } else { "false" })); - out.extend(field_bytes(3, ¶meter)); + // grok-4.7's 500k window is max mode on the same id (AvailableModels, 2026-10-13). + if model == "grok-4.7" { + out.extend(field_varint(2, 1)); + out.extend(field_bytes(3, &model_parameter("context", "500k"))); + } + out.extend(field_bytes( + 3, + &model_parameter("fast", if fast { "true" } else { "false" }), + )); out } @@ -281,24 +526,7 @@ fn build_run_frames( request.extend(field_string(16, &conversation_id)); let first = encode_connect_frame(field_bytes(1, &request), 0); - let cwd = std::env::current_dir() - .ok() - .and_then(|path| path.to_str().map(str::to_string)) - .unwrap_or_default(); - let mut environment = field_string(1, std::env::consts::OS); - environment.extend(field_string(2, &cwd)); - environment.extend(field_string( - 3, - if cfg!(windows) { "powershell" } else { "bash" }, - )); - environment.extend(field_string(10, "UTC")); - environment.extend(field_string(11, &cwd)); - environment.extend(field_varint(14, 1)); - environment.extend(field_varint(16, 1)); - environment.extend(field_varint(19, 0)); - environment.extend(field_varint(20, 0)); - environment.extend(field_string(21, &cwd)); - environment.extend(field_varint(22, 0)); + let environment = request_context_env().encode_to_vec(); let context = field_bytes( 2, &field_bytes( @@ -324,6 +552,57 @@ fn build_run_frames( frames } +/// Answer one agent-store message, keeping the blobs the run was handed. +/// +/// The server writes its conversation state here and reads it back on resume; +/// an unanswered message leaves it waiting on its own checkpoint. +fn kv_reply( + message: &proto::KvServerMessage, + blobs: &mut std::collections::HashMap, Vec>, +) -> Option { + let reply = match message.message.as_ref()? { + proto::kv_server_message::Message::SetBlobArgs(args) => { + blobs.insert(args.blob_id.clone(), args.blob_data.clone()); + proto::kv_client_message::Message::SetBlobResult(proto::SetBlobResult {}) + } + proto::kv_server_message::Message::GetBlobArgs(args) => { + proto::kv_client_message::Message::GetBlobResult(proto::GetBlobResult { + blob_data: Some(blobs.get(&args.blob_id).cloned().unwrap_or_default()), + }) + } + }; + Some(proto::AgentClientMessage { + kv_client_message: Some(proto::KvClientMessage { + id: message.id, + message: Some(reply), + }), + ..Default::default() + }) +} + +/// The environment the agent is told it runs in. +/// +/// Sent preemptively in the run preamble and again as the answer to +/// `request_context_args`: the server needs a populated context in that answer, +/// and an empty one fails the run with "Failed to get request context +/// (internal)". +pub fn request_context_env() -> proto::RequestContextEnv { + let cwd = std::env::current_dir() + .ok() + .and_then(|path| path.to_str().map(str::to_string)) + .unwrap_or_default(); + proto::RequestContextEnv { + os_version: std::env::consts::OS.to_string(), + workspace_paths: vec![cwd.clone()], + shell: if cfg!(windows) { "powershell" } else { "bash" }.to_string(), + time_zone: "UTC".to_string(), + project_folder: cwd.clone(), + sandbox_supported: Some(true), + sandbox_network_has_defaults: Some(true), + process_working_directory: Some(cwd), + } +} + fn heartbeat_frame() -> Bytes { encode_connect_frame(field_bytes(7, &[]), 0) } @@ -450,3 +729,30 @@ impl std::fmt::Display for CursorError { } impl std::error::Error for CursorError {} + +#[cfg(test)] +mod model_message_tests { + use super::*; + + #[test] + fn grok_4_7_requests_max_mode_500k() { + let msg = model_message("grok-4.7", false); + let text = String::from_utf8_lossy(&msg); + assert!(text.contains("grok-4.7")); + assert!(text.contains("context")); + assert!(text.contains("500k")); + assert!(text.contains("fast")); + assert!(text.contains("false")); + assert!(msg.windows(2).any(|pair| pair == [0x10, 0x01])); + } + + #[test] + fn other_models_do_not_request_max_mode() { + let msg = model_message("composer-2.5", true); + let text = String::from_utf8_lossy(&msg); + assert!(text.contains("composer-2.5")); + assert!(text.contains("true")); + assert!(!text.contains("500k")); + assert!(!msg.windows(2).any(|pair| pair == [0x10, 0x01])); + } +} diff --git a/src/providers/cursor/mod.rs b/src/providers/cursor/mod.rs index 3d7732dc..e8b4e208 100644 --- a/src/providers/cursor/mod.rs +++ b/src/providers/cursor/mod.rs @@ -13,7 +13,9 @@ pub mod tool_use_xml; use async_trait::async_trait; use axum::Json; +use axum::body::Body; use axum::response::{IntoResponse, Response}; +use bytes::Bytes; use http::StatusCode; use crate::anthropic::error::json_error; @@ -30,12 +32,10 @@ use crate::providers::cursor::auth::{ use crate::providers::cursor::client::CursorHttpClient; use crate::providers::cursor::model::resolve_cursor_model; use crate::providers::cursor::request::render_cursor_prompt; -use crate::providers::cursor::response::{ - CursorDecodeError, decode_cursor_upstream, decode_upstream_response, -}; +use crate::providers::cursor::response::{CursorDecodeError, decode_cursor_upstream}; use crate::providers::cursor::tool_bridge::{ - BridgeRegistry, advertised_tool_names, can_bridge_cursor_native_tools, find_tool_result, - resume_cursor_tool_bridge, start_cursor_tool_bridge, + BridgeRegistry, LiveBridge, PumpOutcome, advertised_tool_names, can_bridge_cursor_native_tools, + exec_result_messages, find_tool_result, pump_live_bridge, }; // --------------------------------------------------------------------------- @@ -84,28 +84,25 @@ impl Provider for CursorProvider { ); } - if let Some(ref session_id) = ctx.session_id - && let Some(pending) = BridgeRegistry::pending_tool(session_id) - && let Some(result) = find_tool_result(&body, pending.tool_use_id()) + BridgeRegistry::sweep_abandoned(); + + // A live run for this session is waiting on a tool result. Answer it on + // the open stream and keep pumping the same run: a new run here would + // make the agent re-plan and re-issue the call it already made. + if want_stream + && let Some(session_id) = live_session_id(&ctx) + && let Some(bridge) = BridgeRegistry::take(&session_id) { - let (_result_messages, sse_bytes) = - resume_cursor_tool_bridge(session_id, &message_id, model, result, &pending); - if let Some(monitor) = ctx.monitor.as_ref() { - let (input_tokens, output_tokens) = usage_from_anthropic_sse(&sse_bytes); - monitor.stream_progress( - &ctx.req_id, - sse_bytes.len() as u64, - count_sse_events(&sse_bytes), - input_tokens, - output_tokens, - ); + match resume_payload(&bridge, &body) { + Some(preamble) => { + let mut bridge = bridge; + bridge.input_floor = response::estimate_request_input_tokens(&body); + return live_sse_response(bridge, preamble, &message_id, model, &ctx); + } + // No result for the pending exec: the caller moved on. The + // entry is already gone; this request starts its own run. + None => drop(bridge), } - let headers = [ - (http::header::CONTENT_TYPE, "text/event-stream"), - (http::header::CACHE_CONTROL, "no-cache"), - (http::header::CONNECTION, "keep-alive"), - ]; - return (headers, sse_bytes).into_response(); } let auth = match load_cursor_auth() { @@ -143,6 +140,24 @@ impl Provider for CursorProvider { if let Some(monitor) = ctx.monitor.as_ref() { monitor.upstream_started(&ctx.req_id); } + + // With tools in play the run has to stay open across requests, so it is + // opened as a live stream instead of being collected into a body. + if want_stream + && let Some(session_id) = live_session_id(&ctx) + && can_bridge_cursor_native_tools(&body, Some(&session_id)) + { + let allowed = advertised_tool_names(&body); + return match client.start_run(&token, &prompt, model, &images).await { + Ok(run) => { + let mut bridge = LiveBridge::new(session_id, run, allowed); + bridge.input_floor = (prompt.len() / 4).max(1) as u64; + live_sse_response(bridge, Vec::new(), &message_id, model, &ctx) + } + Err(error) => map_cursor_error_to_response(&error), + }; + } + let upstream = match client.run_agent(&token, &prompt, model, &images).await { Ok(r) => r, Err(e) => { @@ -151,60 +166,23 @@ impl Provider for CursorProvider { }; if want_stream { - let session_id = ctx.session_id.as_deref(); - let bridge_eligible = can_bridge_cursor_native_tools(&body, session_id); - - if bridge_eligible { - let events = match decode_upstream_response(&upstream.body) { - Ok(e) => e, - Err(e) => return map_cursor_decode_error_to_response(&e), - }; - - let allowed = advertised_tool_names(&body); - let (sse_bytes, _paused) = start_cursor_tool_bridge( - &message_id, - model, - session_id.unwrap(), - &events, - allowed, - Box::new(|| uuid::Uuid::new_v4().to_string().replace('-', "")), + let sse_bytes = sse::frame_cursor_stream(&upstream, &message_id, model); + if let Some(monitor) = ctx.monitor.as_ref() { + let (input_tokens, output_tokens) = usage_from_anthropic_sse(&sse_bytes); + monitor.stream_progress( + &ctx.req_id, + sse_bytes.len() as u64, + count_sse_events(&sse_bytes), + input_tokens, + output_tokens, ); - if let Some(monitor) = ctx.monitor.as_ref() { - let (input_tokens, output_tokens) = usage_from_anthropic_sse(&sse_bytes); - monitor.stream_progress( - &ctx.req_id, - sse_bytes.len() as u64, - count_sse_events(&sse_bytes), - input_tokens, - output_tokens, - ); - } - - let headers = [ - (http::header::CONTENT_TYPE, "text/event-stream"), - (http::header::CACHE_CONTROL, "no-cache"), - (http::header::CONNECTION, "keep-alive"), - ]; - (headers, sse_bytes).into_response() - } else { - let sse_bytes = sse::frame_cursor_stream(&upstream, &message_id, model); - if let Some(monitor) = ctx.monitor.as_ref() { - let (input_tokens, output_tokens) = usage_from_anthropic_sse(&sse_bytes); - monitor.stream_progress( - &ctx.req_id, - sse_bytes.len() as u64, - count_sse_events(&sse_bytes), - input_tokens, - output_tokens, - ); - } - let headers = [ - (http::header::CONTENT_TYPE, "text/event-stream"), - (http::header::CACHE_CONTROL, "no-cache"), - (http::header::CONNECTION, "keep-alive"), - ]; - (headers, sse_bytes).into_response() } + let headers = [ + (http::header::CONTENT_TYPE, "text/event-stream"), + (http::header::CACHE_CONTROL, "no-cache"), + (http::header::CONNECTION, "keep-alive"), + ]; + (headers, sse_bytes).into_response() } else { match decode_cursor_upstream(&upstream, &message_id, model) { Ok(json) => { @@ -256,17 +234,24 @@ impl Provider for CursorProvider { monitor.model_resolved(&ctx.req_id, &resolved.model_id); } let message_id = format!("msg_{}", uuid::Uuid::new_v4().simple()); - if let Some(session_id) = ctx.session_id.as_deref() - && let Some(pending) = BridgeRegistry::pending_tool(session_id) - && let Some(result) = find_tool_result(&body, pending.tool_use_id()) + + // This surface answers one request at a time, so a parked run is either + // resumed here or dropped; the bytes are collected instead of streamed. + if let Some(session_id) = live_session_id(&ctx) + && let Some(mut bridge) = BridgeRegistry::take(&session_id) { - let (_, bytes) = - resume_cursor_tool_bridge(session_id, &message_id, &requested, result, &pending); - return Ok(Generation { - body: GenerationBody::BufferedSse(bytes.into()), - resolved_model: resolved.model_id, - }); + if let Some(preamble) = resume_payload(&bridge, &body) { + let (bytes, outcome) = + pump_buffered(&mut bridge, preamble, &message_id, &requested).await; + finish_buffered_run(bridge, outcome); + return Ok(Generation { + body: GenerationBody::BufferedSse(bytes.into()), + resolved_model: resolved.model_id, + }); + } + drop(bridge); } + let auth = load_cursor_auth() .map_err(|error| { ProviderError::new( @@ -304,29 +289,49 @@ impl Provider for CursorProvider { if let Some(monitor) = ctx.monitor.as_ref() { monitor.upstream_started(&ctx.req_id); } - let upstream = CursorHttpClient::new() + let client = CursorHttpClient::new(); + + // Tools in play: the run has to stay open across requests, so it is + // pumped as a live stream and its bytes are collected here. + if let Some(session_id) = live_session_id(&ctx) + && can_bridge_cursor_native_tools(&body, Some(&session_id)) + { + let allowed = advertised_tool_names(&body); + let run = client + .start_run(&auth.access_token, &prompt, &requested, &images) + .await + .map_err(cursor_provider_error)?; + let mut bridge = LiveBridge::new(session_id, run, allowed); + let (bytes, outcome) = + pump_buffered(&mut bridge, Vec::new(), &message_id, &requested).await; + finish_buffered_run(bridge, outcome); + if let Some(traffic) = ctx.traffic.as_ref() { + traffic.write_bytes("050-anthropic-intermediate.sse", &bytes); + } + if let Some(monitor) = ctx.monitor.as_ref() { + let (input_tokens, output_tokens) = usage_from_anthropic_sse(&bytes); + monitor.stream_progress( + &ctx.req_id, + bytes.len() as u64, + count_sse_events(&bytes), + input_tokens, + output_tokens, + ); + } + return Ok(Generation { + body: GenerationBody::BufferedSse(bytes.into()), + resolved_model: resolved.model_id, + }); + } + + let upstream = client .run_agent(&auth.access_token, &prompt, &requested, &images) .await .map_err(cursor_provider_error)?; if let Some(traffic) = ctx.traffic.as_ref() { traffic.write_bytes("032-upstream-response-body.bin", &upstream.body); } - let bytes = if can_bridge_cursor_native_tools(&body, ctx.session_id.as_deref()) { - let events = - decode_upstream_response(&upstream.body).map_err(cursor_decode_provider_error)?; - let allowed = advertised_tool_names(&body); - start_cursor_tool_bridge( - &message_id, - &requested, - ctx.session_id.as_deref().expect("bridge session validated"), - &events, - allowed, - Box::new(|| uuid::Uuid::new_v4().simple().to_string()), - ) - .0 - } else { - sse::frame_cursor_stream(&upstream, &message_id, &requested) - }; + let bytes = sse::frame_cursor_stream(&upstream, &message_id, &requested); if let Some(traffic) = ctx.traffic.as_ref() { traffic.write_bytes("050-anthropic-intermediate.sse", &bytes); } @@ -347,6 +352,135 @@ impl Provider for CursorProvider { } } +/// The session id this request can carry a live run on. +fn live_session_id(ctx: &RequestContext) -> Option { + ctx.session_id + .as_deref() + .filter(|id| !id.is_empty()) + .map(str::to_string) +} + +/// The messages that resume a parked run, when this request carries the +/// `tool_result` for the exec it was waiting on. +fn resume_payload( + bridge: &LiveBridge, + body: &MessagesRequest, +) -> Option> { + let pending = bridge.pending.as_ref()?; + let result = find_tool_result(body, &pending.tool_use_id)?; + Some(exec_result_messages(pending, result)) +} + +/// Stream the run's SSE to the client as it is produced. +/// +/// The pump runs in its own task and hands the run back to the registry when it +/// pauses, before the response body ends -- Claude Code answers a pause with the +/// next request, and that request has to find the run. +fn live_sse_response( + mut bridge: LiveBridge, + preamble: Vec, + message_id: &str, + model: &str, + ctx: &RequestContext, +) -> Response { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + let message_id = message_id.to_string(); + let model = model.to_string(); + let monitor = ctx.monitor.clone(); + let req_id = ctx.req_id.clone(); + + tokio::spawn(async move { + let mut sse = Vec::new(); + let mut send_failed = false; + for message in preamble { + if bridge.run.send(&message).await.is_err() { + send_failed = true; + break; + } + } + + let outcome = match send_failed { + true => PumpOutcome::Failed("Cursor run already closed".to_string()), + false => { + pump_live_bridge(&mut bridge, &message_id, &model, &mut |chunk| { + sse.extend_from_slice(chunk); + let _ = tx.send(Bytes::copy_from_slice(chunk)); + }) + .await + } + }; + + match outcome { + PumpOutcome::Tool(pending) => { + bridge.pending = Some(pending); + BridgeRegistry::insert(bridge); + } + // Nothing left to resume: the run is dropped, which closes its + // stream upstream. + PumpOutcome::Replay | PumpOutcome::Ended | PumpOutcome::Failed(_) => drop(bridge), + } + + if let Some(monitor) = monitor { + let (input_tokens, output_tokens) = usage_from_anthropic_sse(&sse); + monitor.stream_progress( + &req_id, + sse.len() as u64, + count_sse_events(&sse), + input_tokens, + output_tokens, + ); + } + drop(tx); + }); + + let body = Body::from_stream(futures_util::stream::unfold(rx, |mut rx| async move { + rx.recv() + .await + .map(|chunk| (Ok::(chunk), rx)) + })); + let headers = [ + (http::header::CONTENT_TYPE, "text/event-stream"), + (http::header::CACHE_CONTROL, "no-cache"), + (http::header::CONNECTION, "keep-alive"), + ]; + (headers, body).into_response() +} + +/// Keep or drop a run the buffered pump stopped on. +fn finish_buffered_run(bridge: LiveBridge, outcome: PumpOutcome) { + match outcome { + PumpOutcome::Tool(pending) => { + let mut bridge = bridge; + bridge.pending = Some(pending); + BridgeRegistry::insert(bridge); + } + PumpOutcome::Replay | PumpOutcome::Ended | PumpOutcome::Failed(_) => drop(bridge), + } +} + +/// Pump a run to its first stop, collecting the SSE (no live client). +async fn pump_buffered( + bridge: &mut LiveBridge, + preamble: Vec, + message_id: &str, + model: &str, +) -> (Vec, PumpOutcome) { + let mut sse = Vec::new(); + for message in preamble { + if bridge.run.send(&message).await.is_err() { + return ( + sse, + PumpOutcome::Failed("Cursor run already closed".to_string()), + ); + } + } + let outcome = pump_live_bridge(bridge, message_id, model, &mut |chunk| { + sse.extend_from_slice(chunk) + }) + .await; + (sse, outcome) +} + fn count_sse_events(bytes: &[u8]) -> u64 { String::from_utf8_lossy(bytes).matches("event:").count() as u64 } @@ -375,15 +509,6 @@ fn cursor_provider_error(err: client::CursorError) -> ProviderError { error } -fn cursor_decode_provider_error(err: CursorDecodeError) -> ProviderError { - let (status, kind) = match err.status() { - Some(401 | 403) => (StatusCode::UNAUTHORIZED, ProviderErrorKind::Authentication), - Some(429) => (StatusCode::TOO_MANY_REQUESTS, ProviderErrorKind::RateLimit), - _ => (StatusCode::BAD_GATEWAY, ProviderErrorKind::Api), - }; - ProviderError::new(status, kind, format!("Response decoding error: {err}")) -} - fn map_cursor_error_to_response(err: &client::CursorError) -> Response { match err.status { 401 | 403 => json_error( diff --git a/src/providers/cursor/proto.rs b/src/providers/cursor/proto.rs index 2a93f14c..b29c5a4c 100644 --- a/src/providers/cursor/proto.rs +++ b/src/providers/cursor/proto.rs @@ -4,11 +4,22 @@ use prost::Message; // Agent client message (request) // --------------------------------------------------------------------------- +// Envelope the client writes into the open `Run` request body. Tag numbers from +// the Cursor Agent descriptors (agent.proto, AgentClientMessage): 1 run_request, +// 2 exec_client_message, 5 exec_client_control_message, 7 client_heartbeat. The +// heartbeat used to sit on tag 2, which is the exec reply -- a reply carrying no +// result reads as "ran and produced nothing" and the server waits forever. #[derive(Clone, PartialEq, Message)] pub struct AgentClientMessage { #[prost(message, optional, tag = "1")] pub run_request: Option, #[prost(message, optional, tag = "2")] + pub exec_client_message: Option, + #[prost(message, optional, tag = "3")] + pub kv_client_message: Option, + #[prost(message, optional, tag = "5")] + pub exec_client_control_message: Option, + #[prost(message, optional, tag = "7")] pub client_heartbeat: Option, } @@ -141,17 +152,106 @@ pub struct SelectedImage { pub struct AgentServerMessage { #[prost(message, optional, tag = "1")] pub interaction_update: Option, - #[prost(message, optional, tag = "3")] + #[prost(message, optional, tag = "2")] pub exec_server_message: Option, + #[prost(message, optional, tag = "3")] + pub conversation_checkpoint_update: Option, + #[prost(message, optional, tag = "4")] + pub kv_server_message: Option, +} + +/// The server's conversation state, sent whole at each step. Only the token +/// counter is read: `token_details` (tag 5) is the size of the context the +/// model saw on its latest step, which is what Claude Code's context gauge +/// means. `TurnEnded` adds up every internal step of a run instead. +#[derive(Clone, PartialEq, Message)] +pub struct ConversationCheckpointUpdate { + #[prost(message, optional, tag = "5")] + pub token_details: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ConversationTokenDetails { + #[prost(uint32, tag = "1")] + pub used_tokens: u32, + #[prost(uint32, tag = "2")] + pub max_tokens: u32, +} + +/// The agent store: the server writes its conversation state here and reads it +/// back on resume. Every message expects a `KvClientMessage` on the same id. +#[derive(Clone, PartialEq, Message)] +pub struct KvServerMessage { + #[prost(uint32, tag = "1")] + pub id: u32, + #[prost(oneof = "kv_server_message::Message", tags = "2, 3")] + pub message: Option, +} + +pub mod kv_server_message { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Message { + #[prost(message, tag = "2")] + GetBlobArgs(super::GetBlobArgs), + #[prost(message, tag = "3")] + SetBlobArgs(super::SetBlobArgs), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct GetBlobArgs { + #[prost(bytes, tag = "1")] + pub blob_id: Vec, +} + +#[derive(Clone, PartialEq, Message)] +pub struct SetBlobArgs { + #[prost(bytes, tag = "1")] + pub blob_id: Vec, + #[prost(bytes, tag = "2")] + pub blob_data: Vec, +} + +#[derive(Clone, PartialEq, Message)] +pub struct KvClientMessage { + #[prost(uint32, tag = "1")] + pub id: u32, + #[prost(oneof = "kv_client_message::Message", tags = "2, 3")] + pub message: Option, +} + +pub mod kv_client_message { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Message { + #[prost(message, tag = "2")] + GetBlobResult(super::GetBlobResult), + #[prost(message, tag = "3")] + SetBlobResult(super::SetBlobResult), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct GetBlobResult { + #[prost(bytes, optional, tag = "1")] + pub blob_data: Option>, } +#[derive(Clone, PartialEq, Message)] +pub struct SetBlobResult {} + +// Tag numbers verified against the Cursor Agent bundle's protobuf descriptors +// (2026.10.01-e373342, 12/10/2026): 1 text_delta, 2 tool_call_started, +// 3 tool_call_completed, 4 thinking_delta, 14 turn_ended. The previous mapping +// (thinking 1, text 2, turn_ended 3) read tool_call_started as the model's text, +// which is why a Cursor tool call reached Claude Code as the literal string +// "call_...\nfc_..." instead of a tool_use block. #[derive(Clone, PartialEq, Message)] pub struct InteractionUpdate { #[prost(message, optional, tag = "1")] - pub thinking_delta: Option, - #[prost(message, optional, tag = "2")] pub text_delta: Option, - #[prost(message, optional, tag = "3")] + #[prost(message, optional, tag = "4")] + pub thinking_delta: Option, + #[prost(message, optional, tag = "14")] pub turn_ended: Option, } @@ -179,8 +279,511 @@ pub struct TurnEnded { pub cache_write_tokens: u64, } +// The frame in which Cursor asks the CLIENT to run a tool. Field numbers from +// the same descriptors: 1 id, 2 shell_args, 3 write_args, 7 read_args, +// 14 shell_stream_args, 15 exec_id. Measured live 12/10/2026: a Read arrives as +// read_args{path, tool_call_id} and a Bash as shell_stream_args with the same +// ShellArgs shape. #[derive(Clone, PartialEq, Message)] pub struct ExecServerMessage { - #[prost(string, optional, tag = "1")] + #[prost(uint32, optional, tag = "1")] + pub id: Option, + #[prost(message, optional, tag = "2")] + pub shell_args: Option, + #[prost(message, optional, tag = "3")] + pub write_args: Option, + #[prost(message, optional, tag = "4")] + pub delete_args: Option, + #[prost(message, optional, tag = "5")] + pub grep_args: Option, + #[prost(message, optional, tag = "7")] + pub read_args: Option, + #[prost(message, optional, tag = "8")] + pub ls_args: Option, + #[prost(message, optional, tag = "10")] + pub request_context_args: Option, + #[prost(message, optional, tag = "14")] + pub shell_stream_args: Option, + #[prost(string, optional, tag = "15")] + pub exec_id: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ReadArgs { + #[prost(string, tag = "1")] + pub path: String, + #[prost(string, optional, tag = "2")] + pub tool_call_id: Option, + #[prost(int32, optional, tag = "4")] + pub offset: Option, + #[prost(uint32, optional, tag = "5")] + pub limit: Option, + #[prost(string, optional, tag = "6")] + pub encoding_hint: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct WriteArgs { + #[prost(string, tag = "1")] + pub path: String, + #[prost(string, tag = "2")] + pub file_text: String, + #[prost(string, optional, tag = "3")] + pub tool_call_id: Option, + #[prost(bool, tag = "4")] + pub return_file_content_after_write: bool, + /// A byte-backed write sets this and leaves `file_text` empty. + #[prost(bytes, optional, tag = "5")] + pub file_bytes: Option>, + /// Charset of the payload (`utf8`, `latin1`, ...), not an encoding of it. + #[prost(string, optional, tag = "6")] + pub encoding_hint: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ShellArgs { + #[prost(string, tag = "1")] + pub command: String, + #[prost(string, optional, tag = "2")] + pub working_directory: Option, + /// Milliseconds: the Cursor agent's own default is 30000 and it feeds the + /// value straight to `setTimeout` (index.js, 2026.10.01 bundle). + #[prost(int32, optional, tag = "3")] + pub timeout: Option, + #[prost(string, optional, tag = "4")] + pub tool_call_id: Option, + #[prost(int32, optional, tag = "14")] + pub hard_timeout: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct DeleteArgs { + #[prost(string, tag = "1")] + pub path: String, + #[prost(string, optional, tag = "2")] + pub tool_call_id: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct GrepArgs { + #[prost(string, tag = "1")] + pub pattern: String, + #[prost(string, optional, tag = "2")] + pub path: Option, + #[prost(string, optional, tag = "14")] + pub tool_call_id: Option, +} + +/// The agent asking for workspace context. Measured live 12/10/2026: it is the +/// first exec of a run, and it carries no id. +#[derive(Clone, PartialEq, Message)] +pub struct RequestContextArgs { + #[prost(string, optional, tag = "2")] pub notes_session_id: Option, } + +#[derive(Clone, PartialEq, Message)] +pub struct LsArgs { + #[prost(string, tag = "1")] + pub path: String, + #[prost(string, optional, tag = "3")] + pub tool_call_id: Option, +} + +// --------------------------------------------------------------------------- +// Agent client exec reply (the answer the server waits for) +// --------------------------------------------------------------------------- + +/// The reply to one `ExecServerMessage`, written back on the same open `Run`. +/// `id` and `exec_id` are echoed unchanged: the id space is shared with the +/// interactions and a result carrying an unknown id is dropped. +#[derive(Clone, PartialEq, Message)] +pub struct ExecClientMessage { + #[prost(uint32, tag = "1")] + pub id: u32, + #[prost(string, tag = "15")] + pub exec_id: String, + #[prost( + oneof = "exec_client_message::Message", + tags = "2, 3, 4, 5, 7, 8, 10, 14" + )] + pub message: Option, +} + +pub mod exec_client_message { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Message { + #[prost(message, tag = "2")] + ShellResult(super::ShellResult), + #[prost(message, tag = "3")] + WriteResult(super::WriteResult), + #[prost(message, tag = "4")] + DeleteResult(super::DeleteResult), + #[prost(message, tag = "5")] + GrepResult(super::GrepResult), + #[prost(message, tag = "7")] + ReadResult(super::ReadResult), + #[prost(message, tag = "8")] + LsResult(super::LsResult), + #[prost(message, tag = "10")] + RequestContextResult(super::RequestContextResult), + #[prost(message, tag = "14")] + ShellStream(super::ShellStream), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct ExecClientControlMessage { + #[prost(oneof = "exec_client_control_message::Message", tags = "1, 2, 3")] + pub message: Option, +} + +pub mod exec_client_control_message { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Message { + #[prost(message, tag = "1")] + StreamClose(super::ExecClientStreamClose), + #[prost(message, tag = "2")] + Throw(super::ExecClientThrow), + #[prost(message, tag = "3")] + Heartbeat(super::ExecClientHeartbeat), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct ExecClientStreamClose { + #[prost(uint32, tag = "1")] + pub id: u32, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ExecClientThrow { + #[prost(uint32, tag = "1")] + pub id: u32, + #[prost(string, tag = "2")] + pub error: String, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ExecClientHeartbeat { + #[prost(uint32, tag = "1")] + pub id: u32, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ReadResult { + #[prost(oneof = "read_result::Result", tags = "1, 2, 3")] + pub result: Option, +} + +pub mod read_result { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(message, tag = "1")] + Success(super::ReadSuccess), + #[prost(message, tag = "2")] + Error(super::ReadError), + #[prost(message, tag = "3")] + Rejected(super::ReadRejected), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct ReadSuccess { + #[prost(string, tag = "1")] + pub path: String, + #[prost(int32, tag = "3")] + pub total_lines: i32, + #[prost(int64, tag = "4")] + pub file_size: i64, + #[prost(bool, tag = "6")] + pub truncated: bool, + #[prost(oneof = "read_success::Output", tags = "2, 5")] + pub output: Option, +} + +pub mod read_success { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Output { + #[prost(string, tag = "2")] + Content(String), + #[prost(bytes, tag = "5")] + Data(Vec), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct ReadError { + #[prost(string, tag = "1")] + pub path: String, + #[prost(string, tag = "2")] + pub error: String, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ReadRejected { + #[prost(string, tag = "1")] + pub path: String, + #[prost(string, tag = "2")] + pub reason: String, +} + +#[derive(Clone, PartialEq, Message)] +pub struct WriteResult { + #[prost(oneof = "write_result::Result", tags = "1, 5, 6")] + pub result: Option, +} + +pub mod write_result { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(message, tag = "1")] + Success(super::WriteSuccess), + #[prost(message, tag = "5")] + Error(super::WriteError), + #[prost(message, tag = "6")] + Rejected(super::WriteRejected), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct WriteSuccess { + #[prost(string, tag = "1")] + pub path: String, + #[prost(int32, tag = "2")] + pub lines_created: i32, + #[prost(int32, tag = "3")] + pub file_size: i32, +} + +#[derive(Clone, PartialEq, Message)] +pub struct WriteError { + #[prost(string, tag = "1")] + pub path: String, + #[prost(string, tag = "2")] + pub error: String, +} + +#[derive(Clone, PartialEq, Message)] +pub struct WriteRejected { + #[prost(string, tag = "1")] + pub path: String, + #[prost(string, tag = "2")] + pub reason: String, +} + +/// `GrepResult` models only the error outcome here: the bridge has no Claude +/// Grep mapping, and a result the bridge never constructs only has to encode. +#[derive(Clone, PartialEq, Message)] +pub struct GrepResult { + #[prost(oneof = "grep_result::Result", tags = "2")] + pub result: Option, +} + +pub mod grep_result { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(message, tag = "2")] + Error(super::GrepError), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct GrepError { + #[prost(string, tag = "1")] + pub error: String, +} + +#[derive(Clone, PartialEq, Message)] +pub struct LsResult { + #[prost(oneof = "ls_result::Result", tags = "3")] + pub result: Option, +} + +pub mod ls_result { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(message, tag = "3")] + Rejected(super::LsRejected), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct LsRejected { + #[prost(string, tag = "1")] + pub path: String, + #[prost(string, tag = "2")] + pub reason: String, +} + +/// The answer to `request_context_args` (tag 10). The agent asks for workspace +/// context before it plans; this bridge renders its own prompt and has none to +/// add, but the request still needs a reply. +#[derive(Clone, PartialEq, Message)] +pub struct RequestContextResult { + #[prost(oneof = "request_context_result::Result", tags = "1")] + pub result: Option, +} + +pub mod request_context_result { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(message, tag = "1")] + Success(super::RequestContextSuccess), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct RequestContext { + #[prost(message, optional, tag = "4")] + pub env: Option, +} + +/// The environment the agent is told it runs in. Measured 12/10/2026: an answer +/// to `request_context_args` without this block makes the server fail the run +/// with "Failed to get request context (internal)". +#[derive(Clone, PartialEq, Message)] +pub struct RequestContextEnv { + #[prost(string, tag = "1")] + pub os_version: String, + #[prost(string, repeated, tag = "2")] + pub workspace_paths: Vec, + #[prost(string, tag = "3")] + pub shell: String, + #[prost(string, tag = "10")] + pub time_zone: String, + #[prost(string, tag = "11")] + pub project_folder: String, + #[prost(bool, optional, tag = "14")] + pub sandbox_supported: Option, + #[prost(bool, optional, tag = "16")] + pub sandbox_network_has_defaults: Option, + #[prost(string, optional, tag = "21")] + pub process_working_directory: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct RequestContextSuccess { + #[prost(message, optional, tag = "1")] + pub request_context: Option, +} + +#[derive(Clone, PartialEq, Message)] +pub struct DeleteResult { + #[prost(oneof = "delete_result::Result", tags = "6")] + pub result: Option, +} + +pub mod delete_result { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(message, tag = "6")] + Rejected(super::DeleteRejected), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct DeleteRejected { + #[prost(string, tag = "1")] + pub path: String, + #[prost(string, tag = "2")] + pub reason: String, +} + +/// One message of a shell execution stream. A stream is a SEQUENCE of these +/// with the same `id`: start, stdout/stderr, then exit. Without the exit the +/// server never closes the execution. +#[derive(Clone, PartialEq, Message)] +pub struct ShellStream { + #[prost(oneof = "shell_stream::Event", tags = "1, 2, 3, 4")] + pub event: Option, +} + +pub mod shell_stream { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Event { + #[prost(message, tag = "1")] + Stdout(super::ShellStreamStdout), + #[prost(message, tag = "2")] + Stderr(super::ShellStreamStderr), + #[prost(message, tag = "3")] + Exit(super::ShellStreamExit), + #[prost(message, tag = "4")] + Start(super::ShellStreamStart), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct ShellStreamStdout { + #[prost(string, tag = "1")] + pub data: String, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ShellStreamStderr { + #[prost(string, tag = "1")] + pub data: String, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ShellStreamExit { + #[prost(uint32, tag = "1")] + pub code: u32, + #[prost(string, tag = "2")] + pub cwd: String, + #[prost(bool, tag = "4")] + pub aborted: bool, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ShellStreamStart {} + +/// A final structured shell result. Cursor can keep the turn pending when it +/// receives only stream deltas, so the exit is followed by this acknowledgement. +#[derive(Clone, PartialEq, Message)] +pub struct ShellResult { + #[prost(oneof = "shell_result::Result", tags = "1, 2")] + pub result: Option, +} + +pub mod shell_result { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(message, tag = "1")] + Success(super::ShellSuccess), + #[prost(message, tag = "2")] + Failure(super::ShellFailure), + } +} + +#[derive(Clone, PartialEq, Message)] +pub struct ShellSuccess { + #[prost(string, tag = "1")] + pub command: String, + #[prost(string, tag = "2")] + pub working_directory: String, + #[prost(int32, tag = "3")] + pub exit_code: i32, + #[prost(string, tag = "5")] + pub stdout: String, + #[prost(string, tag = "6")] + pub stderr: String, + #[prost(int32, tag = "7")] + pub execution_time: i32, +} + +#[derive(Clone, PartialEq, Message)] +pub struct ShellFailure { + #[prost(string, tag = "1")] + pub command: String, + #[prost(string, tag = "2")] + pub working_directory: String, + #[prost(int32, tag = "3")] + pub exit_code: i32, + #[prost(string, tag = "5")] + pub stdout: String, + #[prost(string, tag = "6")] + pub stderr: String, + #[prost(int32, tag = "7")] + pub execution_time: i32, +} diff --git a/src/providers/cursor/request.rs b/src/providers/cursor/request.rs index b9d8fd10..37393549 100644 --- a/src/providers/cursor/request.rs +++ b/src/providers/cursor/request.rs @@ -9,6 +9,17 @@ pub struct CursorSelectedImage { pub mime_type: String, } +/// How the model calls a tool from ``. Without it the agent only knows +/// its own built-in tools: it tried MCP tools and ToolSearch on Cursor's +/// channel, and Cursor answered "Tool not available" (measured 13/10). The +/// format is the one `tool_use_xml` parses and the transcript already uses. +const TOOL_PROTOCOL: &str = "These tools run on the user's machine and are available in this \ +session, although they are not in your built-in tool list. To read files, write files or \ +run shell commands, use your built-in tools. To call any other tool listed here, write the \ +call as text, exactly in this form, and stop: \ +{\"param\": \"value\"} \ +The result comes back as . Never call these tools through your built-in tool channel."; + /// Render the full Cursor prompt from an Anthropic MessagesRequest. /// /// Includes: @@ -35,6 +46,7 @@ pub fn render_cursor_prompt(req: &MessagesRequest) -> String { { let tool_lines: Vec = tools .iter() + .filter(|t| t.get("name").and_then(|n| n.as_str()) != Some("DeferredToolPlaceholder")) .map(|t| { let name = t.get("name").and_then(|n| n.as_str()).unwrap_or(""); let description = t.get("description").and_then(|d| d.as_str()).unwrap_or(""); @@ -51,7 +63,10 @@ pub fn render_cursor_prompt(req: &MessagesRequest) -> String { }) .collect(); if !tool_lines.is_empty() { - sections.push(format!("\n{}\n", tool_lines.join("\n"))); + sections.push(format!( + "\n{TOOL_PROTOCOL}\n\n{}\n", + tool_lines.join("\n") + )); } } @@ -228,6 +243,13 @@ fn render_tool_result_block(block: &serde_json::Value) -> Option { let block_type = block.get("type").and_then(|t| t.as_str())?; match block_type { "text" | "image" | "tool_use" | "tool_result" | "thinking" => render_block(block), + // Tool search: the harness answers ToolSearch with a reference and puts + // the tool's schema in the request's `tools`, where the prompt's + // section already carries it. + "tool_reference" => { + let name = block.get("tool_name").and_then(|n| n.as_str())?; + Some(format!("Tool loaded: {name} (its schema is in ).")) + } _ => { let type_str = block_type.to_string(); Some(format!("[unsupported tool result block: {type_str}]")) @@ -333,6 +355,7 @@ mod tests { .unwrap(); let rendered = render_cursor_prompt(&req); assert!(rendered.contains("")); + assert!(rendered.contains(r#""#)); assert!(rendered.contains("Read")); } @@ -515,4 +538,27 @@ mod tests { let rendered = render_cursor_prompt(&req); assert!(rendered.contains("")); } + + #[test] + fn tool_search_reference_renders_as_a_loaded_tool_and_the_placeholder_is_hidden() { + let req: MessagesRequest = serde_json::from_value(serde_json::json!({ + "model": "cursor:grok-4.7", + "max_tokens": 16, + "messages": [ + {"role": "user", "content": "use whoami"}, + {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "ToolSearch", "input": {"query": "select:whoami"}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": [{"type": "tool_reference", "tool_name": "mcp__x__whoami"}]}]} + ], + "tools": [ + {"name": "DeferredToolPlaceholder", "description": "", "input_schema": {"type": "object"}, "defer_loading": true}, + {"name": "mcp__x__whoami", "description": "who", "input_schema": {"type": "object"}, "defer_loading": true} + ] + })) + .unwrap(); + let rendered = render_cursor_prompt(&req); + assert!(rendered.contains("Tool loaded: mcp__x__whoami")); + assert!(!rendered.contains("unsupported tool result block")); + assert!(!rendered.contains("DeferredToolPlaceholder")); + assert!(rendered.contains("\"name\":\"mcp__x__whoami\"")); + } } diff --git a/src/providers/cursor/response.rs b/src/providers/cursor/response.rs index c4067afb..8dd0d2a7 100644 --- a/src/providers/cursor/response.rs +++ b/src/providers/cursor/response.rs @@ -17,6 +17,9 @@ pub enum CursorStreamEvent { TextDelta { text: String, }, + /// A native exec request: Cursor asking the client to run Read, Write or a + /// shell command. Decoded from ExecServerMessage. + Exec(CursorExecRequest), Usage { input_tokens: u64, output_tokens: u64, @@ -26,6 +29,30 @@ pub enum CursorStreamEvent { End, } +/// A tool the Cursor upstream wants the client to execute. +#[derive(Debug, Clone, PartialEq)] +pub struct CursorExecRequest { + pub exec_id: Option, + pub tool_call_id: Option, + pub kind: CursorExecKind, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CursorExecKind { + Read { + path: String, + }, + Write { + path: String, + content: String, + }, + Shell { + command: String, + working_directory: Option, + timeout_ms: Option, + }, +} + #[derive(Debug, Clone)] pub enum CursorDecodeError { ConnectEnd(ConnectEndError), @@ -279,14 +306,42 @@ fn events_from_current_payload(payload: &[u8], events: &mut Vec) { - // Check for exec_server_message with session info - if let Some(ref exec) = msg.exec_server_message - && let Some(ref session_id) = exec.notes_session_id - && !session_id.is_empty() - { - events.push(CursorStreamEvent::Session { - session_id: session_id.clone(), - }); + // Native exec request: Cursor asks the client to run the tool itself. + if let Some(ref exec) = msg.exec_server_message { + let read = exec.read_args.as_ref(); + let write = exec.write_args.as_ref(); + let shell = exec.shell_stream_args.as_ref().or(exec.shell_args.as_ref()); + + let kind = if let Some(read) = read { + Some(CursorExecKind::Read { + path: read.path.clone(), + }) + } else if let Some(write) = write { + Some(CursorExecKind::Write { + path: write.path.clone(), + content: write.file_text.clone(), + }) + } else { + shell.map(|shell| CursorExecKind::Shell { + command: shell.command.clone(), + working_directory: shell.working_directory.clone(), + timeout_ms: shell + .timeout + .and_then(|timeout| u64::try_from(timeout).ok()), + }) + }; + + if let Some(kind) = kind { + let tool_call_id = read + .and_then(|r| r.tool_call_id.clone()) + .or_else(|| write.and_then(|w| w.tool_call_id.clone())) + .or_else(|| shell.and_then(|s| s.tool_call_id.clone())); + events.push(CursorStreamEvent::Exec(CursorExecRequest { + exec_id: exec.exec_id.clone(), + tool_call_id, + kind, + })); + } } if let Some(ref update) = msg.interaction_update { @@ -332,9 +387,8 @@ pub fn estimate_request_input_tokens(req: &MessagesRequest) -> u64 { mod tests { use super::*; use crate::providers::cursor::connect::encode_connect_frame; - use crate::providers::cursor::proto::*; + use crate::providers::cursor::test_frames; - use prost::Message; #[test] fn decodes_text_and_usage_events() { @@ -366,27 +420,46 @@ mod tests { } } + // The frame a Cursor Read tool call actually arrives in, built with the + // field numbers measured from the live upstream on 2026-10-12: tag 2 is the + // exec server message, tag 7 inside it is read_args, tag 1 of read_args is + // the path and tag 2 is Cursor's own tool_call_id. #[test] - fn decodes_session_event() { - let msg = AgentServerMessage { - interaction_update: None, - exec_server_message: Some(ExecServerMessage { - notes_session_id: Some("session-123".to_string()), - }), - }; - let mut payload = Vec::new(); - msg.encode(&mut payload).unwrap(); - let body = encode_connect_frame(&payload, 0).to_vec(); + fn decodes_native_read_exec() { + let body = test_frames::native_read_exec_frame("/etc/hostname", "tool_abc", "exec-1"); let events = decode_upstream_response(&body).unwrap(); assert_eq!(events.len(), 1); - if let CursorStreamEvent::Session { session_id } = &events[0] { - assert_eq!(session_id, "session-123"); - } else { - panic!("expected Session"); + match &events[0] { + CursorStreamEvent::Exec(exec) => { + assert_eq!(exec.exec_id.as_deref(), Some("exec-1")); + assert_eq!(exec.tool_call_id.as_deref(), Some("tool_abc")); + assert_eq!( + exec.kind, + CursorExecKind::Read { + path: "/etc/hostname".to_string() + } + ); + } + other => panic!("expected Exec, got {other:?}"), } } + /// A model tool call must never reach Claude Code as the model's prose: the + /// shipped mapping read tool_call_started (tag 2) as if it were text_delta. + #[test] + fn tool_call_started_is_not_text() { + let body = test_frames::tool_call_started_frame("call_abc\nfc_123"); + + let events = decode_upstream_response(&body).unwrap(); + assert!( + !events + .iter() + .any(|event| matches!(event, CursorStreamEvent::TextDelta { .. })), + "tool_call_started leaked into the text channel: {events:?}" + ); + } + #[test] fn accumulate_response_produces_anthropic_json() { let mut body = Vec::new(); diff --git a/src/providers/cursor/sse.rs b/src/providers/cursor/sse.rs index 681b1ff5..df3e409f 100644 --- a/src/providers/cursor/sse.rs +++ b/src/providers/cursor/sse.rs @@ -32,8 +32,7 @@ pub fn frame_cursor_stream( } }; - let mut sse = Vec::new(); - let mut framer = CursorSseFramer::new(&mut sse, message_id, model); + let mut framer = CursorSseFramer::new(message_id, model); for event in &events { match event { @@ -43,6 +42,11 @@ pub fn frame_cursor_stream( CursorStreamEvent::TextDelta { text } => { framer.emit_text_delta(text); } + CursorStreamEvent::Exec(_) => { + // Without an advertised Claude tool and a session id the proxy + // cannot hand this exec to the client, and the upstream never + // emitted it as text. Dropping it is the honest outcome. + } CursorStreamEvent::Usage { input_tokens, output_tokens, @@ -66,11 +70,11 @@ pub fn frame_cursor_stream( } framer.finalize(); - sse + framer.take_output() } /// Format an SSE error event. -fn format_sse_error(error: &str) -> Vec { +pub(crate) fn format_sse_error(error: &str) -> Vec { let data = serde_json::json!({ "type": "error", "error": { @@ -92,8 +96,12 @@ pub(crate) fn format_sse_event_bytes(event: &str, data: &serde_json::Value) -> V // --------------------------------------------------------------------------- /// SSE framer that tracks state to produce well-formed Anthropic SSE events. +/// +/// The framer owns its output so a live pump can drain it between events: the +/// framer outlives each event, and a `&mut Vec` output would block the handle +/// that has to hand the bytes to the client right away. pub struct CursorSseFramer<'a> { - output: &'a mut Vec, + output: Vec, message_id: &'a str, model: &'a str, started: bool, @@ -104,13 +112,18 @@ pub struct CursorSseFramer<'a> { text_index: i32, usage_input_tokens: u64, usage_output_tokens: u64, + /// Lower bound for the reported input tokens. Cursor's `turn_ended` can be + /// absent, which used to report 1 token and hide a full context. + input_floor: u64, + /// Context size of the latest model step (checkpoint `used_tokens`). + context_tokens: u64, finalized: bool, } impl<'a> CursorSseFramer<'a> { - pub fn new(output: &'a mut Vec, message_id: &'a str, model: &'a str) -> Self { + pub fn new(message_id: &'a str, model: &'a str) -> Self { Self { - output, + output: Vec::new(), message_id, model, started: false, @@ -121,10 +134,18 @@ impl<'a> CursorSseFramer<'a> { text_index: -1, usage_input_tokens: 0, usage_output_tokens: 0, + input_floor: 0, + context_tokens: 0, finalized: false, } } + /// Take the bytes emitted so far, leaving the framer ready for the next + /// event. State (open blocks, indices, usage) survives the drain. + pub fn take_output(&mut self) -> Vec { + std::mem::take(&mut self.output) + } + pub fn ensure_start(&mut self) { if self.started { return; @@ -142,7 +163,7 @@ impl<'a> CursorSseFramer<'a> { "stop_reason": null, "stop_sequence": null, "usage": { - "input_tokens": self.usage_input_tokens.max(1), + "input_tokens": self.reported_input_tokens(), "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0 @@ -255,6 +276,30 @@ impl<'a> CursorSseFramer<'a> { .extend_from_slice(&format_sse_event_bytes(EVENT_CONTENT_BLOCK_DELTA, &data)); } + pub fn set_input_floor(&mut self, tokens: u64) { + self.input_floor = tokens; + } + + pub fn set_context_tokens(&mut self, tokens: u64) { + self.context_tokens = tokens; + } + + /// The input-token figure Claude Code shows as context. `turn_ended` sums + /// every internal model step of a run, so a run with tool calls reported a + /// multiple of the real context (measured 13/10: 240k for a 27k context). + /// Order: the checkpoint's own counter, then the request-size estimate, + /// and only then Cursor's summed figure. + fn reported_input_tokens(&self) -> u64 { + let tokens = if self.context_tokens > 0 { + self.context_tokens + } else if self.input_floor > 0 { + self.input_floor + } else { + self.usage_input_tokens + }; + tokens.max(1) + } + pub fn record_usage( &mut self, input_tokens: u64, @@ -328,7 +373,7 @@ impl<'a> CursorSseFramer<'a> { "stop_sequence": null }, "usage": { - "input_tokens": self.usage_input_tokens.max(1), + "input_tokens": self.reported_input_tokens(), "output_tokens": self.usage_output_tokens, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0 @@ -534,4 +579,23 @@ mod tests { events } + + #[test] + fn reported_input_follows_the_checkpoint_not_the_summed_turn_usage() { + let sse = |floor: u64, context: u64, summed: u64| { + let mut framer = CursorSseFramer::new("msg_1", "cursor-test"); + framer.set_input_floor(floor); + framer.set_context_tokens(context); + framer.record_usage(summed, 5, 0, 0); + framer.ensure_start(); + framer.finalize(); + String::from_utf8(framer.take_output()).unwrap() + }; + // Checkpoint known: it wins over both the estimate and the sum. + assert!(sse(40_000, 55_000, 240_000).contains("\"input_tokens\":55000")); + // No checkpoint: the request-size estimate, never the summed figure. + assert!(sse(40_000, 0, 240_000).contains("\"input_tokens\":40000")); + // Nothing else known: Cursor's figure, as before. + assert!(sse(0, 0, 240_000).contains("\"input_tokens\":240000")); + } } diff --git a/src/providers/cursor/test_frames.rs b/src/providers/cursor/test_frames.rs index 1a753686..aa0a68b3 100644 --- a/src/providers/cursor/test_frames.rs +++ b/src/providers/cursor/test_frames.rs @@ -53,6 +53,42 @@ pub(crate) fn usage_frame_full( interaction_frame(14, &usage) } +/// The frame a Cursor Read tool call arrives in: AgentServerMessage tag 2 +/// (exec_server_message) -> read_args (tag 7) -> path (tag 1), tool_call_id +/// (tag 2), with exec_id at tag 15. Field numbers measured from the live +/// upstream 2026-10-12. +pub(crate) fn native_read_exec_frame(path: &str, tool_call_id: &str, exec_id: &str) -> Vec { + let mut read_args = bytes_field(1, path.as_bytes()); + read_args.extend(bytes_field(2, tool_call_id.as_bytes())); + let mut exec = varint_field(1, 1); + exec.extend(bytes_field(7, &read_args)); + exec.extend(bytes_field(15, exec_id.as_bytes())); + encode_connect_frame(bytes_field(2, &exec), 0).to_vec() +} + +/// A grep exec request: AgentServerMessage tag 2 (exec_server_message) -> +/// grep_args (tag 5) -> pattern (tag 1), path (tag 2), with the exec id at +/// tag 15. Shapes from the Cursor Agent descriptors (agent/v1/grep_exec.proto). +pub(crate) fn native_grep_exec_frame(pattern: &str, path: &str, id: u64, exec_id: &str) -> Vec { + let mut grep_args = bytes_field(1, pattern.as_bytes()); + grep_args.extend(bytes_field(2, path.as_bytes())); + let mut exec = varint_field(1, id); + exec.extend(bytes_field(5, &grep_args)); + exec.extend(bytes_field(15, exec_id.as_bytes())); + encode_connect_frame(bytes_field(2, &exec), 0).to_vec() +} + +/// InteractionUpdate tool_call_started (tag 2). The old proto mapping read this +/// as text_delta and sent Cursor's tool call to Claude Code as prose. +pub(crate) fn tool_call_started_frame(tool_call_id: &str) -> Vec { + let mut started = bytes_field(1, tool_call_id.as_bytes()); + started.extend(bytes_field( + 3, + b"be193bcf-0000-0000-0000-000000000000-0-aho4", + )); + interaction_frame(2, &started) +} + pub(crate) fn end_frame() -> Vec { encode_connect_frame(b"", 2).to_vec() } diff --git a/src/providers/cursor/tool_bridge.rs b/src/providers/cursor/tool_bridge.rs index 3c740adc..a74775f7 100644 --- a/src/providers/cursor/tool_bridge.rs +++ b/src/providers/cursor/tool_bridge.rs @@ -1,235 +1,173 @@ -//! Cursor tool bridge: state machine, result builders, pending tool tracking, -//! stream re-entry, and SSE pause/resume. +//! Cursor tool bridge: the live exec pump and the registry of open runs. //! -//! The bridge coordinates the pause-and-continue lifecycle when the Cursor -//! upstream emits a `` text block. The bridge pauses the SSE stream, -//! stores the pending tool, and waits for Claude's `tool_result` in the next -//! client request. On resume it builds Cursor protocol result messages and -//! continues producing SSE output from stored upstream events. +//! `AgentService/Run` is bidirectional. One run stays open for the whole turn, +//! and every `ExecServerMessage` the agent sends is answered on that same +//! stream with an `ExecClientMessage` carrying the echoed `id`/`exec_id`. The +//! bridge drives that loop: it turns upstream events into Anthropic SSE, pauses +//! when the agent asks for a tool, and hands the still-open run back to the +//! registry. The next Claude Code request (which carries the `tool_result`) +//! resumes that run instead of starting a new one -- a new run makes the agent +//! re-plan from scratch and re-issue the tool call it was already waiting on. use std::collections::BTreeSet; use std::sync::Mutex; +use std::time::{Duration, Instant}; use once_cell::sync::Lazy; use crate::anthropic::schema::MessagesRequest; -use crate::providers::cursor::response::CursorStreamEvent; -use crate::providers::cursor::sse::CursorSseFramer; +use crate::providers::cursor::client::{LiveEvent, LiveRun}; +use crate::providers::cursor::proto; +use crate::providers::cursor::sse::{CursorSseFramer, format_sse_error}; use crate::providers::cursor::tool_use_xml::{CursorToolUseXmlParser, RecoveredCursorEvent}; +/// A run untouched for this long was abandoned (the user aborted, the client +/// died) and is dropped, closing its HTTP/2 stream. +const RUN_MAX_IDLE: Duration = Duration::from_secs(600); + +/// Claude Code's Bash tool accepts at most this many milliseconds. +const MAX_BASH_TIMEOUT_MS: i64 = 600_000; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -/// Execution context for a Cursor tool. -#[derive(Debug, Clone, PartialEq)] -pub struct CursorExec { - pub id: Option, - pub exec_id: Option, - pub args: serde_json::Value, -} - -/// A tool result produced by Claude. +/// The tool Claude Code was asked to run for a Cursor exec. #[derive(Debug, Clone, PartialEq)] -pub struct CursorNativeToolResult { - pub content: String, - pub is_error: bool, -} - -/// A pending Cursor tool that Claude must fulfill. -#[derive(Debug, Clone)] -pub enum PendingCursorTool { +pub enum PendingExecKind { Read { - tool_use_id: String, path: String, }, Write { - tool_use_id: String, path: String, content: String, }, - Bash { - tool_use_id: String, + Shell { command: String, - working_directory: String, - timeout_ms: u64, + cwd: String, + /// `shell_stream_args` (tag 14) streams; bare `shell_args` (tag 2) is + /// answered with a single result. + stream: bool, }, } -impl PendingCursorTool { - pub fn name(&self) -> &'static str { - match self { - Self::Read { .. } => "Read", - Self::Write { .. } => "Write", - Self::Bash { .. } => "Bash", - } - } - - pub fn tool_use_id(&self) -> &str { - match self { - Self::Read { tool_use_id, .. } - | Self::Write { tool_use_id, .. } - | Self::Bash { tool_use_id, .. } => tool_use_id, - } - } - - /// Build the JSON input that matches the Claude tool_use block. - pub fn input_json(&self) -> serde_json::Value { - match self { - Self::Read { path, .. } => { - serde_json::json!({ "file_path": path }) - } - Self::Write { path, content, .. } => { - serde_json::json!({ "file_path": path, "content": content }) - } - Self::Bash { - command, - working_directory, - timeout_ms, - .. - } => { - let cmd = if working_directory.is_empty() { - command.clone() - } else { - format!("cd '{}' && {command}", working_directory) - }; - serde_json::json!({ - "command": cmd, - "timeout": timeout_ms, - "description": "Run Cursor-requested shell command", - "run_in_background": false, - "dangerouslyDisableSandbox": false - }) - } - } - } +/// A Cursor exec handed to Claude Code, awaiting its `tool_result`. +#[derive(Debug, Clone, PartialEq)] +pub struct PendingExec { + /// The `tool_use` id Claude Code answers with. + pub tool_use_id: String, + /// Echoed back unchanged: the server drops a result it cannot match. + pub id: u32, + pub exec_id: String, + pub kind: PendingExecKind, } -/// Bridge state stored per session. +/// Why the pump stopped. #[derive(Debug)] -pub struct CursorBridgeState { +pub enum PumpOutcome { + /// Claude Code owes this exec a result; the run stays open. + Tool(PendingExec), + /// A tool_use recovered from the text channel. The run cannot answer it, so + /// it ends here and the next request rebuilds the prompt with the result. + Replay, + /// The turn ended. + Ended, + Failed(String), +} + +/// Everything kept between two Claude Code requests of the same session. +pub struct LiveBridge { pub session_id: String, - pub message_id: String, - pub model: String, - pub pending_tool: Option, - pub remaining_events: Vec, - pub event_cursor: usize, - pub input_tokens: u64, - pub output_tokens: u64, - pub allowed_tool_names: Option>, - pub xml_parser: CursorToolUseXmlParser, + pub run: LiveRun, + pub pending: Option, + pub allowed: Option>, + /// Estimated size of the request that opened or resumed the run. + pub input_floor: u64, + last_activity: Instant, } -impl CursorBridgeState { - fn new( - session_id: String, - message_id: String, - model: String, - allowed_tool_names: Option>, - id_factory: Box String + Send>, - ) -> Self { +impl LiveBridge { + pub fn new(session_id: String, run: LiveRun, allowed: Option>) -> Self { Self { session_id, - message_id, - model, - pending_tool: None, - remaining_events: Vec::new(), - event_cursor: 0, - input_tokens: 0, - output_tokens: 0, - allowed_tool_names: allowed_tool_names.clone(), - xml_parser: CursorToolUseXmlParser::new_with_id_factory(allowed_tool_names, id_factory), + run, + pending: None, + allowed, + input_floor: 0, + last_activity: Instant::now(), } } -} - -// --------------------------------------------------------------------------- -// Global bridge registry -// --------------------------------------------------------------------------- - -static BRIDGE_REGISTRY: Lazy> = - Lazy::new(|| Mutex::new(BridgeRegistryInner::new())); -struct BridgeRegistryInner { - sessions: Vec, + fn touch(&mut self) { + self.last_activity = Instant::now(); + } } -impl BridgeRegistryInner { - fn new() -> Self { - Self { - sessions: Vec::new(), - } +impl std::fmt::Debug for LiveBridge { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LiveBridge") + .field("session_id", &self.session_id) + .field("pending", &self.pending) + .finish() } } -/// Global registry of active bridge sessions. -pub struct BridgeRegistry; +// --------------------------------------------------------------------------- +// Registry of open runs +// --------------------------------------------------------------------------- -impl BridgeRegistry { - /// Insert a new bridge state for a session. - pub fn insert(state: CursorBridgeState) { - let mut reg = BRIDGE_REGISTRY.lock().unwrap(); - reg.sessions.push(state); - } +static BRIDGE_REGISTRY: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); - /// Get the bridge state for a session. - pub fn get(session_id: &str) -> Option { - let reg = BRIDGE_REGISTRY.lock().unwrap(); - reg.sessions.iter().position(|s| s.session_id == session_id) - } +/// One open run per session. +/// +/// Every path out of the pump removes the entry: a finished turn, a dead run +/// and a new run for the same session all drop it, and [`Self::sweep`] drops +/// what nobody came back for. An entry without a pending exec never blocks the +/// next request's own exec -- the caller only resumes when the request carries +/// the matching `tool_result`, and drops the entry otherwise. +pub struct BridgeRegistry; - /// Get the pending tool for a session (if any). - pub fn pending_tool(session_id: &str) -> Option { - let reg = BRIDGE_REGISTRY.lock().unwrap(); - reg.sessions - .iter() - .find(|s| s.session_id == session_id) - .and_then(|s| s.pending_tool.clone()) +impl BridgeRegistry { + /// Store a run, replacing any entry the session already had. + pub fn insert(bridge: LiveBridge) { + let mut registry = BRIDGE_REGISTRY.lock().unwrap(); + registry.retain(|entry| entry.session_id != bridge.session_id); + registry.push(bridge); } - /// Take the bridge state for a session (removes it). - pub fn take(session_id: &str) -> Option { - let mut reg = BRIDGE_REGISTRY.lock().unwrap(); - let pos = reg - .sessions + /// Remove and return the session's run. + pub fn take(session_id: &str) -> Option { + let mut registry = BRIDGE_REGISTRY.lock().unwrap(); + let position = registry .iter() - .position(|s| s.session_id == session_id)?; - Some(reg.sessions.swap_remove(pos)) + .position(|entry| entry.session_id == session_id)?; + Some(registry.swap_remove(position)) } - /// Remove a bridge state for a session. pub fn remove(session_id: &str) { - let mut reg = BRIDGE_REGISTRY.lock().unwrap(); - reg.sessions.retain(|s| s.session_id != session_id); + let mut registry = BRIDGE_REGISTRY.lock().unwrap(); + registry.retain(|entry| entry.session_id != session_id); } - /// Insert or update the pending tool for a session. - pub fn set_pending_tool(session_id: &str, tool: PendingCursorTool) { - let mut reg = BRIDGE_REGISTRY.lock().unwrap(); - if let Some(state) = reg.sessions.iter_mut().find(|s| s.session_id == session_id) { - state.pending_tool = Some(tool); - } + /// Drop runs idle for longer than `max_idle`, returning how many went. + pub fn sweep(max_idle: Duration) -> usize { + let mut registry = BRIDGE_REGISTRY.lock().unwrap(); + let before = registry.len(); + registry.retain(|entry| entry.last_activity.elapsed() < max_idle); + before - registry.len() } - /// Update usage for a session. - pub fn record_usage(session_id: &str, input_tokens: u64, output_tokens: u64) { - let mut reg = BRIDGE_REGISTRY.lock().unwrap(); - if let Some(state) = reg.sessions.iter_mut().find(|s| s.session_id == session_id) { - state.input_tokens = input_tokens.max(state.input_tokens); - state.output_tokens = output_tokens.max(state.output_tokens); - } + pub fn sweep_abandoned() -> usize { + Self::sweep(RUN_MAX_IDLE) } - /// Clear all bridge state. pub fn clear() { - let mut reg = BRIDGE_REGISTRY.lock().unwrap(); - reg.sessions.clear(); + let mut registry = BRIDGE_REGISTRY.lock().unwrap(); + registry.clear(); } - /// Number of active sessions. pub fn active_count() -> usize { - let reg = BRIDGE_REGISTRY.lock().unwrap(); - reg.sessions.len() + BRIDGE_REGISTRY.lock().unwrap().len() } } @@ -270,10 +208,6 @@ pub fn can_bridge_cursor_native_tools(body: &MessagesRequest, session_id: Option names.contains("Read") || names.contains("Write") || names.contains("Bash") } -// --------------------------------------------------------------------------- -// Result helpers -// --------------------------------------------------------------------------- - /// Find the last `tool_result` block matching `tool_use_id` in the request. pub fn find_tool_result<'a>( body: &'a MessagesRequest, @@ -333,754 +267,1346 @@ pub fn tool_result_is_error(result: &serde_json::Value) -> bool { .unwrap_or(false) } -/// Build the partial JSON string for a pending tool's input (for the -/// input_json_delta in the tool_use content block). -pub fn build_tool_use_input_json(tool: &PendingCursorTool) -> String { - serde_json::to_string(&tool.input_json()).unwrap_or_else(|_| "{}".to_string()) -} - -// --------------------------------------------------------------------------- -// Cursor protocol message builders -// --------------------------------------------------------------------------- - -/// Inject `id` and `execId` fields into a JSON payload. -pub fn with_exec_ids( - exec: &CursorExec, - mut payload: serde_json::Map, -) -> serde_json::Value { - if let Some(id) = exec.id { - payload.insert("id".into(), id.into()); - } - if let Some(ref exec_id) = exec.exec_id { - payload.insert("execId".into(), exec_id.clone().into()); - } - serde_json::Value::Object(payload) -} - -/// Build the Cursor `readResult` message from a Claude `tool_result`. -pub fn build_read_result_from_native( - exec: &CursorExec, - result: &CursorNativeToolResult, -) -> serde_json::Value { - let path = exec - .args - .get("file_path") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let content = &result.content; - let lines = if content.is_empty() { - 0 - } else { - content.lines().count() - }; - let file_size = content.len().to_string(); - - let read_result = serde_json::json!({ - "success": { - "path": path, - "content": content, - "totalLines": lines, - "fileSize": file_size - } - }); - - let mut map = serde_json::Map::new(); - map.insert("readResult".into(), read_result); - with_exec_ids(exec, map) -} - -/// Build the Cursor `writeResult` message from a Claude `tool_result`. -pub fn build_write_result_from_native( - exec: &CursorExec, - result: &CursorNativeToolResult, -) -> serde_json::Value { - let path = exec - .args - .get("file_path") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let write_result = if result.is_error { - serde_json::json!({ - "error": { - "path": path, - "error": result.content - } - }) - } else { - let lines = if result.content.is_empty() { - 0 - } else { - result.content.lines().count() - }; - serde_json::json!({ - "success": { - "path": path, - "linesCreated": lines, - "fileSize": result.content.len() - } - }) - }; - - let mut map = serde_json::Map::new(); - map.insert("writeResult".into(), write_result); - with_exec_ids(exec, map) -} - -/// Build the collection of Cursor `shellStream` messages from a Claude -/// `tool_result`. -/// -/// Returns: start, stdout/stderr, exit, streamClose. -pub fn build_shell_stream_result( - exec: &CursorExec, - result: &CursorNativeToolResult, - local_execution_time: std::time::Duration, - cwd: &str, -) -> Vec { - let mut messages: Vec = Vec::new(); - - // Start - let start_msg = with_exec_ids( - exec, - serde_json::json!({ "shellStream": { "start": {} } }) - .as_object() - .cloned() - .unwrap_or_default(), - ); - messages.push(start_msg); - - // Content (stdout or stderr) - if !result.content.is_empty() { - let stream_key = if result.is_error { "stderr" } else { "stdout" }; - let content_msg = with_exec_ids( - exec, - serde_json::json!({ "shellStream": { stream_key: { "data": result.content } } }) - .as_object() - .cloned() - .unwrap_or_default(), - ); - messages.push(content_msg); - } - - // Exit - let exit_code: u32 = if result.is_error { 1 } else { 0 }; - let exit_msg = with_exec_ids( - exec, - serde_json::json!({ - "shellStream": { - "exit": { - "code": exit_code, - "cwd": cwd, - "localExecutionTimeMs": local_execution_time.as_millis() as u64, - } - } - }) - .as_object() - .cloned() - .unwrap_or_default(), - ); - messages.push(exit_msg); - - // Stream close - if let Some(id) = exec.id { - let close_msg = serde_json::json!({ - "execClientControlMessage": { - "streamClose": { - "id": id - } - } - }); - messages.push(close_msg); - } else { - let close_msg = serde_json::json!({ - "execClientControlMessage": { - "streamClose": {} - } - }); - messages.push(close_msg); - } - - messages -} - // --------------------------------------------------------------------------- -// Bridge start and resume +// Live pump // --------------------------------------------------------------------------- -/// Start a new tool bridge session. -/// -/// Processes upstream events through XML recovery. When a `` is -/// recovered, emits the SSE pause (tool_use content block + message_stop with -/// stop_reason="tool_use") and stores the bridge state for resume. +/// Run the live pump: upstream events in, Anthropic SSE out. /// -/// Returns the SSE bytes and whether a tool_use pause was emitted. -pub fn start_cursor_tool_bridge( +/// Stops at the first exec the agent asks for (the run stays open for the +/// answer), at the end of the turn, or on a stream error. Bytes are handed to +/// `sink` as they are produced, so the client sees deltas while the turn runs. +pub async fn pump_live_bridge( + bridge: &mut LiveBridge, message_id: &str, model: &str, - session_id: &str, - events: &[CursorStreamEvent], - allowed_tool_names: Option>, - id_factory: Box String + Send>, -) -> (Vec, bool) { - let mut sse = Vec::new(); - let mut framer = CursorSseFramer::new(&mut sse, message_id, model); - - let mut state = CursorBridgeState::new( - session_id.to_string(), - message_id.to_string(), - model.to_string(), - allowed_tool_names, - id_factory, - ); - - let mut paused = false; - - for event in events { - if paused { - state.remaining_events.push(event.clone()); - continue; - } + sink: &mut (dyn FnMut(&[u8]) + Send), +) -> PumpOutcome { + let mut framer = CursorSseFramer::new(message_id, model); + framer.set_input_floor(bridge.input_floor); + let mut xml = CursorToolUseXmlParser::new(bridge.allowed.clone()); + + loop { + let event = match bridge.run.events.recv().await { + Some(event) => event, + // The reader task only ends with the stream; treat it as the turn. + None => { + flush_xml(&mut xml, &mut framer, sink); + framer.finalize(); + sink(&framer.take_output()); + return PumpOutcome::Ended; + } + }; + bridge.touch(); match event { - CursorStreamEvent::ThinkingDelta { text } => { - framer.emit_thinking_delta(text); - } - CursorStreamEvent::TextDelta { text } => { - let recovered = state.xml_parser.push(text); - for recovered_event in &recovered { - if paused { - if let RecoveredCursorEvent::Text(t) = recovered_event { - state - .remaining_events - .push(CursorStreamEvent::TextDelta { text: t.clone() }); - } - continue; - } - match recovered_event { - RecoveredCursorEvent::Text(t) => { - framer.emit_text_delta(t); + LiveEvent::Message(message) => { + if let Some(exec) = message.exec_server_message.as_ref() { + match exec_action(exec, bridge.allowed.as_ref()) { + ExecAction::Call { name, input, kind } => { + let tool_use_id = format!("toolu_{}", uuid::Uuid::new_v4().simple()); + let input_json = + serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string()); + framer.emit_tool_pause(&tool_use_id, name, &input_json); + sink(&framer.take_output()); + return PumpOutcome::Tool(PendingExec { + tool_use_id, + id: exec.id.unwrap_or(0), + exec_id: exec.exec_id.clone().unwrap_or_default(), + kind, + }); } - RecoveredCursorEvent::ToolUse(tool_use) => { - let input_json = serde_json::to_string(&tool_use.input) - .unwrap_or_else(|_| "{}".to_string()); - framer.emit_tool_pause(&tool_use.id, &tool_use.name, &input_json); - - if let Some(pending) = pending_from_recovered_tool(tool_use) { - state.pending_tool = Some(pending); + ExecAction::Answer(reply) => { + // Never leave the request half-answered: the agent + // keeps its plan as long as every exec gets a reply. + if !answer(exec, reply, &bridge.run).await { + framer.finalize(); + sink(&framer.take_output()); + return PumpOutcome::Ended; } - - paused = true; } } } - } - CursorStreamEvent::Usage { - input_tokens, - output_tokens, - .. - } => { - framer.record_usage(*input_tokens, *output_tokens, 0, 0); - state.input_tokens = *input_tokens; - state.output_tokens = *output_tokens; - } - CursorStreamEvent::Session { .. } => { - // Session info is not mapped to SSE events - } - CursorStreamEvent::End => { - // If we haven't paused, finalize normally - if !paused { - // Process any remaining XML before finalizing - let flushed = state.xml_parser.flush(); - for evt in &flushed { - if let RecoveredCursorEvent::ToolUse(tool_use) = evt { - let input_json = serde_json::to_string(&tool_use.input) - .unwrap_or_else(|_| "{}".to_string()); - framer.emit_tool_pause(&tool_use.id, &tool_use.name, &input_json); - if let Some(pending) = pending_from_recovered_tool(tool_use) { - state.pending_tool = Some(pending); - } - paused = true; - } + + if let Some(details) = message + .conversation_checkpoint_update + .as_ref() + .and_then(|checkpoint| checkpoint.token_details.as_ref()) + { + framer.set_context_tokens(u64::from(details.used_tokens)); + } + if let Some(update) = message.interaction_update.as_ref() { + if let Some(thinking) = update.thinking_delta.as_ref() + && !thinking.text.is_empty() + { + framer.emit_thinking_delta(&thinking.text); + } + if let Some(text) = update.text_delta.as_ref() + && !text.text.is_empty() + && pump_text(&text.text, &mut xml, &mut framer) + { + sink(&framer.take_output()); + return PumpOutcome::Replay; } - if !paused { - framer.finalize(); + if let Some(usage) = update.turn_ended.as_ref() { + framer.record_usage( + usage.input_tokens, + usage.output_tokens, + usage.cache_read_tokens, + usage.cache_write_tokens, + ); } } + + sink(&framer.take_output()); + } + LiveEvent::End => { + flush_xml(&mut xml, &mut framer, sink); + framer.finalize(); + sink(&framer.take_output()); + return PumpOutcome::Ended; + } + LiveEvent::Error(error) => { + framer.finalize(); + sink(&framer.take_output()); + sink(&format_sse_error(&error)); + return PumpOutcome::Failed(error); } } } +} - if paused { - let remaining = state.remaining_events.clone(); - let mut stored_state = CursorBridgeState::new( - session_id.to_string(), - message_id.to_string(), - model.to_string(), - state.allowed_tool_names.clone(), - Box::new(|| { - format!( - "call_cursor_{}", - uuid::Uuid::new_v4().to_string().replace('-', "") - ) - }), - ); - stored_state.pending_tool = state.pending_tool.clone(); - stored_state.remaining_events = remaining; - stored_state.event_cursor = 0; - stored_state.input_tokens = state.input_tokens; - stored_state.output_tokens = state.output_tokens; - BridgeRegistry::insert(stored_state); - } - - if !paused { - // Flush any remaining text from XML parser - let flushed = state.xml_parser.flush(); - for evt in &flushed { - if let RecoveredCursorEvent::ToolUse(tool_use) = evt { +/// Feed one text delta through the XML recovery parser. +/// +/// Returns `true` when a `` was recovered: the text channel cannot +/// answer it, so the pump stops there. +fn pump_text( + text: &str, + xml: &mut CursorToolUseXmlParser, + framer: &mut CursorSseFramer<'_>, +) -> bool { + for event in xml.push(text) { + match event { + RecoveredCursorEvent::Text(text) => framer.emit_text_delta(&text), + RecoveredCursorEvent::ToolUse(tool_use) => { let input_json = serde_json::to_string(&tool_use.input).unwrap_or_else(|_| "{}".to_string()); framer.emit_tool_pause(&tool_use.id, &tool_use.name, &input_json); - if let Some(pending) = pending_from_recovered_tool(tool_use) { - state.pending_tool = Some(pending); - } - paused = true; + return true; } } - if !paused { - framer.finalize(); + } + false +} + +fn flush_xml( + xml: &mut CursorToolUseXmlParser, + framer: &mut CursorSseFramer<'_>, + sink: &mut (dyn FnMut(&[u8]) + Send), +) { + for event in xml.flush() { + match event { + RecoveredCursorEvent::Text(text) => framer.emit_text_delta(&text), + RecoveredCursorEvent::ToolUse(tool_use) => { + let input_json = + serde_json::to_string(&tool_use.input).unwrap_or_else(|_| "{}".to_string()); + framer.emit_tool_pause(&tool_use.id, &tool_use.name, &input_json); + } } } + sink(&framer.take_output()); +} + +// --------------------------------------------------------------------------- +// Exec mapping +// --------------------------------------------------------------------------- - (sse, paused) +/// What the bridge does with one exec request. +enum ExecAction { + /// Hand it to Claude Code and pause. + Call { + name: &'static str, + input: serde_json::Value, + kind: PendingExecKind, + }, + /// Answer it here: a type the bridge has no Claude tool for, or a write it + /// refuses. + Answer(ExecAnswer), } -/// Resume a paused tool bridge session. -/// -/// Finds the stored state by session_id, resolves the pending tool with -/// Claude's `tool_result`, and continues producing SSE from remaining events. -pub fn resume_cursor_tool_bridge( - session_id: &str, - new_message_id: &str, - new_model: &str, - result: &serde_json::Value, - pending_tool: &PendingCursorTool, -) -> (Vec, Vec) { - let native_result = CursorNativeToolResult { - content: render_tool_result_content(result), - is_error: tool_result_is_error(result), - }; +/// The in-band answer to an exec the bridge serves itself. +enum ExecAnswer { + /// A typed result. The oneof is never left empty: the server reads that as + /// "the tool ran and produced nothing". + Result(proto::exec_client_message::Message), + /// A variant this build cannot name. `throw` travels on the control + /// channel and is the protocol's failure path, not an empty result. + Unsupported, +} - // Build Cursor protocol messages for the resolved tool - let exec = CursorExec { - id: None, - exec_id: None, - args: pending_tool.input_json(), - }; - let result_messages = match pending_tool { - PendingCursorTool::Read { .. } => { - let msg = build_read_result_from_native(&exec, &native_result); - vec![msg] +fn exec_action(exec: &proto::ExecServerMessage, allowed: Option<&BTreeSet>) -> ExecAction { + let allows = |name: &str| allowed.is_none_or(|names| names.contains(name)); + + if let Some(read) = exec.read_args.as_ref() { + if !allows("Read") { + return ExecAction::Answer(ExecAnswer::Result( + proto::exec_client_message::Message::ReadResult(proto::ReadResult { + result: Some(proto::read_result::Result::Rejected(proto::ReadRejected { + path: read.path.clone(), + reason: "Read is not available to this client".to_string(), + })), + }), + )); } - PendingCursorTool::Write { .. } => { - let msg = build_write_result_from_native(&exec, &native_result); - vec![msg] + let mut input = serde_json::json!({ "file_path": read.path }); + // Cursor may index the window from the end with a negative offset; + // Claude Code's Read has no such form, and a wrong line number is worse + // than reading the whole file. + if let Some(offset) = read.offset.filter(|offset| *offset >= 0) { + input["offset"] = offset.into(); } - PendingCursorTool::Bash { - working_directory, .. - } => build_shell_stream_result( - &exec, - &native_result, - std::time::Duration::from_millis(0), - working_directory, - ), - }; - - // Generate SSE continuation from remaining events - let mut sse = Vec::new(); - let mut framer = CursorSseFramer::new(&mut sse, new_message_id, new_model); - - // Retrieve stored state for remaining events - let remaining: Vec = BridgeRegistry::pending_tool(session_id) - .and_then(|_| BridgeRegistry::take(session_id)) - .map(|state| state.remaining_events) - .unwrap_or_default(); - - if remaining.is_empty() { - // No remaining events: just finalize - framer.finalize(); - } else { - let mut xml_parser = CursorToolUseXmlParser::new(None); - let mut paused_again = false; - - for event in &remaining { - match event { - CursorStreamEvent::ThinkingDelta { text } => { - if !paused_again { - framer.emit_thinking_delta(text); - } - } - CursorStreamEvent::TextDelta { text } => { - if paused_again { - continue; - } - let recovered = xml_parser.push(text); - for evt in &recovered { - match evt { - RecoveredCursorEvent::Text(t) => { - framer.emit_text_delta(t); - } - RecoveredCursorEvent::ToolUse(tool_use) => { - let input_json = serde_json::to_string(&tool_use.input) - .unwrap_or_else(|_| "{}".to_string()); - framer.emit_tool_pause(&tool_use.id, &tool_use.name, &input_json); - paused_again = true; - } - } - } - } - CursorStreamEvent::Usage { - input_tokens, - output_tokens, - .. - } => { - if !paused_again { - framer.record_usage(*input_tokens, *output_tokens, 0, 0); - } - } - CursorStreamEvent::Session { .. } => {} - CursorStreamEvent::End => { - if !paused_again { - // Flush before finalizing - let flushed = xml_parser.flush(); - for evt in &flushed { - if let RecoveredCursorEvent::ToolUse(tool_use) = evt { - let input_json = serde_json::to_string(&tool_use.input) - .unwrap_or_else(|_| "{}".to_string()); - framer.emit_tool_pause(&tool_use.id, &tool_use.name, &input_json); - paused_again = true; - } - } - if !paused_again { - framer.finalize(); - } - } - } - } + if let Some(limit) = read.limit.filter(|limit| *limit > 0) { + input["limit"] = limit.into(); } + return ExecAction::Call { + name: "Read", + input, + kind: PendingExecKind::Read { + path: read.path.clone(), + }, + }; + } - if !paused_again { - let flushed = xml_parser.flush(); - for evt in &flushed { - if let RecoveredCursorEvent::ToolUse(tool_use) = evt { - let input_json = - serde_json::to_string(&tool_use.input).unwrap_or_else(|_| "{}".to_string()); - framer.emit_tool_pause(&tool_use.id, &tool_use.name, &input_json); - paused_again = true; - } - } - if !paused_again { - framer.finalize(); + if let Some(write) = exec.write_args.as_ref() { + let content = match write_content(write) { + Ok(content) => content, + Err(error) => { + // Answering instead of writing an empty file over a real one. + return ExecAction::Answer(ExecAnswer::Result( + proto::exec_client_message::Message::WriteResult(proto::WriteResult { + result: Some(proto::write_result::Result::Error(proto::WriteError { + path: write.path.clone(), + error, + })), + }), + )); } - } - - if paused_again && !remaining.is_empty() { - let state = CursorBridgeState::new( - session_id.to_string(), - new_message_id.to_string(), - new_model.to_string(), - None, - Box::new(|| { - format!( - "call_cursor_{}", - uuid::Uuid::new_v4().to_string().replace('-', "") - ) + }; + if !allows("Write") { + return ExecAction::Answer(ExecAnswer::Result( + proto::exec_client_message::Message::WriteResult(proto::WriteResult { + result: Some(proto::write_result::Result::Rejected( + proto::WriteRejected { + path: write.path.clone(), + reason: "Write is not available to this client".to_string(), + }, + )), }), - ); - BridgeRegistry::insert(state); + )); } + return ExecAction::Call { + name: "Write", + input: serde_json::json!({ "file_path": write.path, "content": content }), + kind: PendingExecKind::Write { + path: write.path.clone(), + content, + }, + }; } - (result_messages, sse) -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/// Create a `PendingCursorTool` from a recovered XML tool_use event. -fn pending_from_recovered_tool( - tool_use: &crate::providers::cursor::tool_use_xml::RecoveredCursorToolUse, -) -> Option { - match tool_use.name.as_str() { - "Read" => { - let file_path = tool_use - .input - .get("file_path") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - Some(PendingCursorTool::Read { - tool_use_id: tool_use.id.clone(), - path: file_path, - }) - } - "Write" => { - let file_path = tool_use - .input - .get("file_path") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let content = tool_use - .input - .get("content") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - Some(PendingCursorTool::Write { - tool_use_id: tool_use.id.clone(), - path: file_path, - content, - }) + if let Some(shell) = exec.shell_stream_args.as_ref().or(exec.shell_args.as_ref()) { + let stream = exec.shell_stream_args.is_some(); + if !allows("Bash") { + return ExecAction::Answer(ExecAnswer::Result( + proto::exec_client_message::Message::ShellResult(rejected_shell_result( + shell, + "Bash is not available to this client", + )), + )); } - "Bash" => { - let command = tool_use - .input - .get("command") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let working_directory = String::new(); - let timeout_ms = tool_use - .input - .get("timeout") - .and_then(|v| v.as_u64()) - .unwrap_or(30_000); - Some(PendingCursorTool::Bash { - tool_use_id: tool_use.id.clone(), - command, - working_directory, - timeout_ms, - }) + let cwd = shell.working_directory.clone().unwrap_or_default(); + let command = match cwd.is_empty() { + true => shell.command.clone(), + // Claude Code's Bash has no cwd field, so the directory rides in + // the command. The exec's cwd is what the agent expects to run in. + false => format!("cd {} && {}", shell_quote(&cwd), shell.command), + }; + let mut input = serde_json::json!({ "command": command }); + if let Some(timeout) = shell_timeout_ms(shell) { + input["timeout"] = timeout.into(); } - _ => None, + return ExecAction::Call { + name: "Bash", + input, + kind: PendingExecKind::Shell { + command: shell.command.clone(), + cwd, + stream, + }, + }; } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::anthropic::schema::MessagesRequest; - use std::sync::Mutex; - - /// Serialize tests that share the global bridge registry. - static REGISTRY_LOCK: Mutex<()> = Mutex::new(()); - - // ----------------------------------------------------------------------- - // PendingCursorTool tests - // ----------------------------------------------------------------------- - #[test] - fn pending_read_input_matches_claude_read_tool() { - let tool = PendingCursorTool::Read { - tool_use_id: "call_cursor_1".into(), - path: "/tmp/a".into(), - }; - let json = tool.input_json(); - assert_eq!(json["file_path"], "/tmp/a"); - assert_eq!(tool.name(), "Read"); - assert_eq!(tool.tool_use_id(), "call_cursor_1"); + if let Some(grep) = exec.grep_args.as_ref() { + return ExecAction::Answer(ExecAnswer::Result( + proto::exec_client_message::Message::GrepResult(proto::GrepResult { + result: Some(proto::grep_result::Result::Error(proto::GrepError { + error: format!( + "This client has no Grep tool wired to Cursor: pattern {:?} not searched", + grep.pattern + ), + })), + }), + )); } - #[test] - fn pending_write_input_matches_claude_write_tool() { - let tool = PendingCursorTool::Write { - tool_use_id: "call_cursor_2".into(), - path: "/tmp/b".into(), - content: "hello".into(), - }; - let json = tool.input_json(); - assert_eq!(json["file_path"], "/tmp/b"); - assert_eq!(json["content"], "hello"); - assert_eq!(tool.name(), "Write"); + if let Some(ls) = exec.ls_args.as_ref() { + return ExecAction::Answer(ExecAnswer::Result( + proto::exec_client_message::Message::LsResult(proto::LsResult { + result: Some(proto::ls_result::Result::Rejected(proto::LsRejected { + path: ls.path.clone(), + reason: "This client has no directory listing wired to Cursor".to_string(), + })), + }), + )); } - #[test] - fn pending_bash_input_matches_claude_bash_tool() { - let tool = PendingCursorTool::Bash { - tool_use_id: "call_cursor_3".into(), - command: "pwd".into(), - working_directory: "/tmp".into(), - timeout_ms: 30_000, - }; - let json = tool.input_json(); - assert_eq!(json["command"], "cd '/tmp' && pwd"); - assert_eq!(json["timeout"], 30_000); - assert_eq!(json["description"], "Run Cursor-requested shell command"); - assert_eq!(tool.name(), "Bash"); + if let Some(delete) = exec.delete_args.as_ref() { + return ExecAction::Answer(ExecAnswer::Result( + proto::exec_client_message::Message::DeleteResult(proto::DeleteResult { + result: Some(proto::delete_result::Result::Rejected( + proto::DeleteRejected { + path: delete.path.clone(), + reason: "This client has no Delete tool wired to Cursor".to_string(), + }, + )), + }), + )); } - #[test] - fn pending_bash_no_working_directory() { - let tool = PendingCursorTool::Bash { - tool_use_id: "call_cursor_4".into(), - command: "ls".into(), - working_directory: "".into(), - timeout_ms: 10_000, - }; - let json = tool.input_json(); - // Without a working directory, command is passed as-is - assert_eq!(json["command"], "ls"); + if exec.request_context_args.is_some() { + // The agent asks for the workspace context. This client renders the + // prompt itself, so the only part it can answer honestly is the + // environment -- and the answer has to carry one, or the server fails + // the whole run with "Failed to get request context". + return ExecAction::Answer(ExecAnswer::Result( + proto::exec_client_message::Message::RequestContextResult( + proto::RequestContextResult { + result: Some(proto::request_context_result::Result::Success( + proto::RequestContextSuccess { + request_context: Some(proto::RequestContext { + env: Some(crate::providers::cursor::client::request_context_env()), + }), + }, + )), + }, + ), + )); } - // ----------------------------------------------------------------------- - // Result builder tests - // ----------------------------------------------------------------------- + ExecAction::Answer(ExecAnswer::Unsupported) +} - #[test] - fn with_exec_ids_adds_id_and_exec_id() { - let exec = CursorExec { - id: Some(7), - exec_id: Some("exec-1".into()), - args: serde_json::json!({}), - }; - let mut payload = serde_json::Map::new(); - payload.insert("test".into(), serde_json::json!("value")); - let result = with_exec_ids(&exec, payload); - assert_eq!(result["id"], 7); - assert_eq!(result["execId"], "exec-1"); - assert_eq!(result["test"], "value"); +/// The text a `WriteArgs` carries. +/// +/// A byte-backed write leaves `file_text` empty; writing it as an empty file +/// would destroy whatever is at `path`. +fn write_content(write: &proto::WriteArgs) -> Result { + if !write.file_text.is_empty() { + return Ok(write.file_text.clone()); + } + match write.file_bytes.as_ref() { + Some(bytes) if !bytes.is_empty() => String::from_utf8(bytes.clone()).map_err(|_| { + let hint = write + .encoding_hint + .as_deref() + .map(|hint| format!(" (encoding hint {hint:?})")) + .unwrap_or_default(); + format!( + "Refusing to write {} bytes that are not UTF-8{hint}: this bridge writes text only", + bytes.len() + ) + }), + _ => Ok(String::new()), } +} - #[test] - fn with_exec_ids_omits_missing_fields() { - let exec = CursorExec { - id: None, - exec_id: None, - args: serde_json::json!({}), - }; - let mut payload = serde_json::Map::new(); - payload.insert("test".into(), serde_json::json!("v")); - let result = with_exec_ids(&exec, payload); - assert!(result.get("id").is_none()); - assert!(result.get("execId").is_none()); - assert_eq!(result["test"], "v"); +/// Cursor's shell timeout, converted to the milliseconds Claude Code's Bash +/// takes. +/// +/// Measured on the wire 12/10/2026 (official CLI run): `timeout` 30000 with +/// `hard_timeout` 86400000 -- both milliseconds, matching the agent bundle, +/// which feeds the value to `setTimeout` and defaults it to 30000. +fn shell_timeout_ms(shell: &proto::ShellArgs) -> Option { + let requested = match shell.timeout { + Some(timeout) if timeout > 0 => i64::from(timeout), + _ => match shell.hard_timeout { + Some(hard) if hard > 0 => i64::from(hard), + _ => return None, + }, + }; + Some(requested.min(MAX_BASH_TIMEOUT_MS)) +} + +/// Quote a path for `cd &&` inside a shell command. +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn rejected_shell_result(shell: &proto::ShellArgs, reason: &str) -> proto::ShellResult { + proto::ShellResult { + result: Some(proto::shell_result::Result::Failure(proto::ShellFailure { + command: shell.command.clone(), + working_directory: shell.working_directory.clone().unwrap_or_default(), + exit_code: 1, + stdout: String::new(), + stderr: reason.to_string(), + execution_time: 0, + })), } +} - #[test] - fn read_result_from_successful_result() { - let exec = CursorExec { - id: Some(1), - exec_id: None, - args: serde_json::json!({"file_path": "/tmp/a"}), - }; - let result = CursorNativeToolResult { - content: "file content".into(), - is_error: false, - }; - let msg = build_read_result_from_native(&exec, &result); - assert_eq!(msg["id"], 1); - assert_eq!(msg["readResult"]["success"]["path"], "/tmp/a"); - assert_eq!(msg["readResult"]["success"]["content"], "file content"); - assert_eq!(msg["readResult"]["success"]["totalLines"], 1); +// --------------------------------------------------------------------------- +// Writing answers back on the open run +// --------------------------------------------------------------------------- + +/// Send one exec answer, then close the exec stream. +/// +/// Returns `false` when the run is gone; the pump stops rather than pretend the +/// answer landed. +async fn answer(exec: &proto::ExecServerMessage, message: ExecAnswer, run: &LiveRun) -> bool { + let id = exec.id.unwrap_or(0); + let messages = match message { + ExecAnswer::Result(result) => vec![ + proto::AgentClientMessage { + exec_client_message: Some(proto::ExecClientMessage { + id, + exec_id: exec.exec_id.clone().unwrap_or_default(), + message: Some(result), + }), + ..Default::default() + }, + stream_close(id), + ], + ExecAnswer::Unsupported => vec![ + proto::AgentClientMessage { + exec_client_control_message: Some(proto::ExecClientControlMessage { + message: Some(proto::exec_client_control_message::Message::Throw( + proto::ExecClientThrow { + id, + error: "Unknown exec message variant".to_string(), + }, + )), + }), + ..Default::default() + }, + stream_close(id), + ], + }; + + for message in messages { + if run.send(&message).await.is_err() { + return false; + } } + true +} - #[test] - fn write_result_from_successful_result() { - let exec = CursorExec { - id: Some(2), - exec_id: None, - args: serde_json::json!({"file_path": "/tmp/b", "content": "hi"}), - }; - let result = CursorNativeToolResult { - content: "success".into(), - is_error: false, +/// Claude Code's Read answers in `cat -n` form (` 1\talpha`), and may add +/// `` text after the file. Cursor's `ReadSuccess.content` is +/// the file itself: handing it the numbered form made the agent write the +/// numbers back into the file (measured 13/10: `1\tbeta` on disk). +fn raw_file_text(read_output: &str) -> String { + let mut lines = Vec::new(); + for line in read_output.lines() { + let digits = line.trim_start(); + let Some(tab) = digits.find('\t') else { break }; + if tab == 0 || !digits[..tab].bytes().all(|b| b.is_ascii_digit()) { + break; + } + lines.push(&digits[tab + 1..]); + } + if lines.is_empty() { + // An empty file comes back as a reminder only; anything else is not + // the numbered form and passes through untouched. + return if read_output.trim_start().starts_with("") { + String::new() + } else { + read_output.to_string() }; - let msg = build_write_result_from_native(&exec, &result); - assert_eq!(msg["id"], 2); - assert_eq!(msg["writeResult"]["success"]["path"], "/tmp/b"); - assert_eq!(msg["writeResult"]["success"]["linesCreated"], 1); } + lines.join("\n") +} - #[test] - fn write_result_from_error_result() { - let exec = CursorExec { - id: Some(3), - exec_id: None, - args: serde_json::json!({"file_path": "/tmp/c"}), - }; - let result = CursorNativeToolResult { - content: "permission denied".into(), - is_error: true, - }; - let msg = build_write_result_from_native(&exec, &result); - assert_eq!(msg["writeResult"]["error"]["path"], "/tmp/c"); - assert_eq!(msg["writeResult"]["error"]["error"], "permission denied"); +fn stream_close(id: u32) -> proto::AgentClientMessage { + proto::AgentClientMessage { + exec_client_control_message: Some(proto::ExecClientControlMessage { + message: Some(proto::exec_client_control_message::Message::StreamClose( + proto::ExecClientStreamClose { id }, + )), + }), + ..Default::default() } +} - #[test] - fn shell_stream_result_emits_start_output_exit_and_close() { - let exec = CursorExec { - id: Some(7), - exec_id: Some("e".into()), - args: serde_json::json!({}), +// --------------------------------------------------------------------------- +// Claude Code result -> Cursor result +// --------------------------------------------------------------------------- + +/// Build the reply for a `tool_result` Claude Code produced. +pub fn exec_result_messages( + pending: &PendingExec, + result: &serde_json::Value, +) -> Vec { + let content = render_tool_result_content(result); + let is_error = tool_result_is_error(result); + + let message = match &pending.kind { + PendingExecKind::Read { path } => { + proto::exec_client_message::Message::ReadResult(proto::ReadResult { + result: Some(if is_error { + proto::read_result::Result::Error(proto::ReadError { + path: path.clone(), + error: content, + }) + } else { + let content = raw_file_text(&content); + proto::read_result::Result::Success(proto::ReadSuccess { + path: path.clone(), + total_lines: content.lines().count() as i32, + file_size: content.len() as i64, + truncated: false, + output: Some(proto::read_success::Output::Content(content)), + }) + }), + }) + } + PendingExecKind::Write { + path, + content: written, + } => proto::exec_client_message::Message::WriteResult(proto::WriteResult { + result: Some(if is_error { + proto::write_result::Result::Error(proto::WriteError { + path: path.clone(), + error: content, + }) + } else { + proto::write_result::Result::Success(proto::WriteSuccess { + path: path.clone(), + lines_created: written.lines().count() as i32, + file_size: written.len() as i32, + }) + }), + }), + PendingExecKind::Shell { stream, .. } => { + let code = u32::from(is_error); + if *stream { + return shell_stream_messages(pending, &content, code, is_error); + } + proto::exec_client_message::Message::ShellResult(shell_result( + pending, + &content, + code as i32, + is_error, + )) + } + }; + + vec![ + client_message(pending, message), + stream_close_message(pending), + ] +} + +/// The structured shell outcome: a success or a failure with the same text. +/// +/// The command and directory travel with it: they are what the server renders +/// into the tool message the model reads. +fn shell_result( + pending: &PendingExec, + content: &str, + code: i32, + is_error: bool, +) -> proto::ShellResult { + let (command, working_directory) = match &pending.kind { + PendingExecKind::Shell { command, cwd, .. } => (command.clone(), shell_cwd(cwd)), + _ => (String::new(), shell_cwd("")), + }; + let (stdout, stderr) = match is_error { + true => (String::new(), content.to_string()), + false => (content.to_string(), String::new()), + }; + proto::ShellResult { + result: Some(if is_error { + proto::shell_result::Result::Failure(proto::ShellFailure { + command, + working_directory, + exit_code: code, + stdout, + stderr, + execution_time: 0, + }) + } else { + proto::shell_result::Result::Success(proto::ShellSuccess { + command, + working_directory, + exit_code: code, + stdout, + stderr, + execution_time: 0, + }) + }), + } +} + +/// The directory a shell exec ran in: what the agent asked for, or the proxy's +/// own working directory when it named none. +fn shell_cwd(cwd: &str) -> String { + if !cwd.is_empty() { + return cwd.to_string(); + } + std::env::current_dir() + .ok() + .and_then(|path| path.to_str().map(str::to_string)) + .unwrap_or_default() +} + +/// The streamed shell answer: stdout, exit, the structured acknowledgement +/// Cursor needs to close the run, then the stream close. +fn shell_stream_messages( + pending: &PendingExec, + content: &str, + code: u32, + is_error: bool, +) -> Vec { + let cwd = match &pending.kind { + PendingExecKind::Shell { cwd, .. } => shell_cwd(cwd), + _ => shell_cwd(""), + }; + + let mut stream = vec![proto::shell_stream::Event::Start( + proto::ShellStreamStart {}, + )]; + if !content.is_empty() { + stream.push(if is_error { + proto::shell_stream::Event::Stderr(proto::ShellStreamStderr { + data: content.to_string(), + }) + } else { + proto::shell_stream::Event::Stdout(proto::ShellStreamStdout { + data: content.to_string(), + }) + }); + } + stream.push(proto::shell_stream::Event::Exit(proto::ShellStreamExit { + code, + cwd, + aborted: false, + })); + + let messages = stream + .into_iter() + .map(|event| { + client_message( + pending, + proto::exec_client_message::Message::ShellStream(proto::ShellStream { + event: Some(event), + }), + ) + }) + .chain(std::iter::once(client_message( + pending, + proto::exec_client_message::Message::ShellResult(shell_result( + pending, + content, + code as i32, + is_error, + )), + ))); + + messages + .chain(std::iter::once(stream_close_message(pending))) + .collect() +} + +fn client_message( + pending: &PendingExec, + message: proto::exec_client_message::Message, +) -> proto::AgentClientMessage { + proto::AgentClientMessage { + exec_client_message: Some(proto::ExecClientMessage { + id: pending.id, + exec_id: pending.exec_id.clone(), + message: Some(message), + }), + ..Default::default() + } +} + +fn stream_close_message(pending: &PendingExec) -> proto::AgentClientMessage { + proto::AgentClientMessage { + exec_client_control_message: Some(proto::ExecClientControlMessage { + message: Some(proto::exec_client_control_message::Message::StreamClose( + proto::ExecClientStreamClose { id: pending.id }, + )), + }), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::cursor::test_frames; + use prost::Message as _; + use std::sync::Mutex; + + /// Serialize tests that share the global bridge registry. + static REGISTRY_LOCK: Mutex<()> = Mutex::new(()); + + fn exec_message(id: u32, exec_id: &str) -> proto::ExecServerMessage { + proto::ExecServerMessage { + id: Some(id), + exec_id: Some(exec_id.to_string()), + ..Default::default() + } + } + + fn read_exec(id: u32, exec_id: &str, path: &str) -> proto::ExecServerMessage { + let mut exec = exec_message(id, exec_id); + exec.read_args = Some(proto::ReadArgs { + path: path.to_string(), + ..Default::default() + }); + exec + } + + fn answer_of(action: ExecAction) -> ExecAnswer { + match action { + ExecAction::Answer(answer) => answer, + ExecAction::Call { name, .. } => { + panic!("expected an in-band answer, got a {name} call") + } + } + } + + fn result_of(action: ExecAction) -> proto::exec_client_message::Message { + match answer_of(action) { + ExecAnswer::Result(result) => result, + ExecAnswer::Unsupported => panic!("expected a typed result, got a throw"), + } + } + + // ----------------------------------------------------------------------- + // Codec: the frames the server reads back, byte for byte + // ----------------------------------------------------------------------- + + /// Hand-written bytes for a read answer. prost writes fields in tag order, + /// so `exec_id` (tag 15) lands after the result: + /// AgentClientMessage tag 2 (exec_client_message) len 36 > + /// id tag 1 = 7, + /// read_result tag 7 len 21 > success tag 1 len 19 > + /// path tag 1 "/tmp/a", content tag 2 (inside the output oneof) "hello", + /// total_lines tag 3 = 1, file_size tag 4 = 5, + /// exec_id tag 15 = "exec-read". + #[test] + fn read_result_frame_matches_hand_written_bytes() { + let client = proto::AgentClientMessage { + exec_client_message: Some(proto::ExecClientMessage { + id: 7, + exec_id: "exec-read".to_string(), + message: Some(proto::exec_client_message::Message::ReadResult( + proto::ReadResult { + result: Some(proto::read_result::Result::Success(proto::ReadSuccess { + path: "/tmp/a".to_string(), + total_lines: 1, + file_size: 5, + truncated: false, + output: Some(proto::read_success::Output::Content("hello".to_string())), + })), + }, + )), + }), + ..Default::default() }; - let messages = build_shell_stream_result( - &exec, - &CursorNativeToolResult { - content: "hi".into(), - is_error: false, + + assert_eq!( + hex::encode(client.encode_to_vec()), + concat!( + "1224", // exec_client_message, 36 bytes + "0807", // id = 7 + "3a15", // read_result, 21 bytes + "0a13", // success, 19 bytes + "0a062f746d702f61", // path = "/tmp/a" + "120568656c6c6f", // output.content = "hello" + "1801", // total_lines = 1 + "2005", // file_size = 5 + "7a09657865632d72656164", // exec_id = "exec-read" + ) + ); + } + + /// Hand-written bytes for a write answer: + /// AgentClientMessage tag 2 (exec_client_message) len 18 > + /// id tag 1 = 3, exec_id empty (stays off the wire as the default), + /// write_result tag 3 len 14 > success tag 1 len 12 > + /// path tag 1 "/tmp/b", lines_created tag 2 = 2, file_size tag 3 = 11. + #[test] + fn write_result_frame_matches_hand_written_bytes() { + let client = proto::AgentClientMessage { + exec_client_message: Some(proto::ExecClientMessage { + id: 3, + exec_id: String::new(), + message: Some(proto::exec_client_message::Message::WriteResult( + proto::WriteResult { + result: Some(proto::write_result::Result::Success(proto::WriteSuccess { + path: "/tmp/b".to_string(), + lines_created: 2, + file_size: 11, + })), + }, + )), + }), + ..Default::default() + }; + + assert_eq!( + hex::encode(client.encode_to_vec()), + concat!( + "1212", // exec_client_message, 18 bytes + "0803", // id = 3 + "1a0e", // write_result, 14 bytes + "0a0c", // success, 12 bytes + "0a062f746d702f62", // path = "/tmp/b" + "1002", // lines_created = 2 + "180b", // file_size = 11 + ) + ); + } + + /// The shell stream: a sequence on ONE id that ends with `exit`. Without + /// the exit the server never closes the execution. + #[test] + fn shell_stream_frames_match_hand_written_bytes() { + let pending = PendingExec { + tool_use_id: "toolu_1".to_string(), + id: 9, + exec_id: "e".to_string(), + kind: PendingExecKind::Shell { + command: "echo hi".to_string(), + cwd: "/tmp".to_string(), + stream: true, }, - std::time::Duration::from_millis(3), - "/tmp", + }; + let result = serde_json::json!({ + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": "hi" + }); + + let messages = exec_result_messages(&pending, &result); + let frames: Vec = messages + .iter() + .filter_map(|message| message.exec_client_message.as_ref()) + .map(|message| hex::encode(message.encode_to_vec())) + .collect(); + + // Each entry is the ExecClientMessage the AgentClientMessage wraps. + assert_eq!( + frames, + vec![ + // start: id = 9, shell_stream tag 14 len 2 > start tag 4, + // empty, exec_id "e" + "0809720222007a0165".to_string(), + // stdout: shell_stream len 6 > stdout tag 1 len 4 > data "hi" + "080972060a040a0268697a0165".to_string(), + // exit: code 0 is the proto3 default and stays off the wire, + // cwd = "/tmp"; aborted=false likewise. + "080972081a0612042f746d707a0165".to_string(), + // the structured acknowledgement Cursor needs to close the + // step: shell_result tag 2 len 21 > success tag 1 len 19 > + // command tag 1 = "echo hi", working_directory tag 2 = "/tmp", + // stdout tag 5 = "hi" + "080912150a130a076563686f20686912042f746d702a0268697a0165".to_string(), + ] ); - assert_eq!(messages.len(), 4); - // Start - assert!( - messages[0] - .get("shellStream") - .and_then(|s| s.get("start")) - .is_some() + + // ... and the control message that closes the exec stream. + assert_eq!( + hex::encode(messages.last().expect("stream close").encode_to_vec()), + // exec_client_control_message tag 5 len 4 > stream_close tag 1 + // len 2 > id = 9 + "2a040a020809" ); - // Stdout content - assert_eq!(messages[1]["shellStream"]["stdout"]["data"], "hi"); - // Exit - assert_eq!(messages[2]["shellStream"]["exit"]["code"], 0); - assert_eq!(messages[2]["shellStream"]["exit"]["cwd"], "/tmp"); - // Stream close + } + + /// A run that asks for a tool Claude Code can serve: the pump pauses with + /// the exec's id/exec_id/kind, and the reply echoes both ids back. + #[test] + fn read_tool_result_echoes_the_exec_ids_on_the_reply() { + let pending = PendingExec { + tool_use_id: "toolu_7".to_string(), + id: 7, + exec_id: "exec-read".to_string(), + kind: PendingExecKind::Read { + path: "/tmp/a".to_string(), + }, + }; + let result = serde_json::json!({ + "type": "tool_result", + "tool_use_id": "toolu_7", + "content": "file body", + "is_error": false + }); + + let messages = exec_result_messages(&pending, &result); + let reply = messages + .first() + .and_then(|message| message.exec_client_message.as_ref()) + .expect("read result"); + assert_eq!(reply.id, 7, "the exec id has to be echoed exactly"); + assert_eq!(reply.exec_id, "exec-read"); + match reply.message.as_ref() { + Some(proto::exec_client_message::Message::ReadResult(result)) => { + match result.result.as_ref() { + Some(proto::read_result::Result::Success(success)) => { + assert_eq!(success.path, "/tmp/a"); + assert_eq!( + success.output, + Some(proto::read_success::Output::Content( + "file body".to_string() + )) + ); + } + other => panic!("expected a read success, got {other:?}"), + } + } + other => panic!("expected a read result, got {other:?}"), + } + + // And the same ids close the exec stream. + let close = messages + .last() + .and_then(|message| message.exec_client_control_message.as_ref()) + .and_then(|control| control.message.as_ref()) + .expect("stream close"); + match close { + proto::exec_client_control_message::Message::StreamClose(close) => { + assert_eq!(close.id, 7) + } + other => panic!("expected a streamClose, got {other:?}"), + } + } + + #[test] + fn failed_read_tool_result_becomes_a_typed_read_error() { + let pending = PendingExec { + tool_use_id: "toolu_8".to_string(), + id: 8, + exec_id: "exec-read".to_string(), + kind: PendingExecKind::Read { + path: "/nope".to_string(), + }, + }; + let result = serde_json::json!({ + "type": "tool_result", + "tool_use_id": "toolu_8", + "content": "ENOENT", + "is_error": true + }); + + let messages = exec_result_messages(&pending, &result); + match messages[0].exec_client_message.as_ref() { + Some(message) => match message.message.as_ref() { + Some(proto::exec_client_message::Message::ReadResult(result)) => { + match result.result.as_ref() { + Some(proto::read_result::Result::Error(error)) => { + assert_eq!(error.path, "/nope"); + assert_eq!(error.error, "ENOENT"); + } + other => panic!("expected a read error, got {other:?}"), + } + } + other => panic!("expected a read result, got {other:?}"), + }, + None => panic!("missing exec message"), + } + } + + // ----------------------------------------------------------------------- + // Decode: an exec with its ids + // ----------------------------------------------------------------------- + + #[test] + fn decodes_grep_exec_with_ids() { + let frame = test_frames::native_grep_exec_frame("TODO", "/tmp", 4, "exec-grep"); + let mut decoder = crate::providers::cursor::connect::ConnectFrameDecoder::new(); + let frames = decoder.push(&frame).unwrap(); + let message = crate::providers::cursor::client::decode_frame_payload(&frames[0]).unwrap(); + let exec = message.exec_server_message.expect("exec frame"); + assert_eq!(exec.id, Some(4)); + assert_eq!(exec.exec_id.as_deref(), Some("exec-grep")); + assert_eq!(exec.grep_args.as_ref().unwrap().pattern, "TODO"); assert_eq!( - messages[3]["execClientControlMessage"]["streamClose"]["id"], - 7 + exec.grep_args.as_ref().unwrap().path.as_deref(), + Some("/tmp") ); } + /// An exec the bridge cannot serve still has to be answered, and the answer + /// never carries an empty oneof: the server reads that as "ran and produced + /// nothing" and waits for a reply that will not come. #[test] - fn shell_stream_handles_error_result() { - let exec = CursorExec { - id: Some(8), - exec_id: None, - args: serde_json::json!({}), + fn unsupported_exec_produces_a_typed_result_never_an_empty_oneof() { + let mut grep_exec = exec_message(11, "exec-grep"); + grep_exec.grep_args = Some(proto::GrepArgs { + pattern: "TODO".to_string(), + path: None, + tool_call_id: None, + }); + + match result_of(exec_action(&grep_exec, None)) { + proto::exec_client_message::Message::GrepResult(result) => match result.result { + Some(proto::grep_result::Result::Error(error)) => { + assert!(error.error.contains("TODO")) + } + other => panic!("expected a grep error, got {other:?}"), + }, + other => panic!("expected a grep result, got {other:?}"), + } + + let mut ls_exec = exec_message(12, "exec-ls"); + ls_exec.ls_args = Some(proto::LsArgs { + path: "/tmp".to_string(), + tool_call_id: None, + }); + assert!(matches!( + result_of(exec_action(&ls_exec, None)), + proto::exec_client_message::Message::LsResult(_) + )); + + let mut delete_exec = exec_message(13, "exec-delete"); + delete_exec.delete_args = Some(proto::DeleteArgs { + path: "/tmp/a".to_string(), + tool_call_id: None, + }); + assert!(matches!( + result_of(exec_action(&delete_exec, None)), + proto::exec_client_message::Message::DeleteResult(_) + )); + + // A variant this build cannot name goes out as a throw, not as an + // empty result. + let mut unknown = exec_message(14, "exec-unknown"); + unknown.exec_id = Some("exec-unknown".to_string()); + assert!(matches!( + answer_of(exec_action(&unknown, None)), + ExecAnswer::Unsupported + )); + + // And every one of those survives the wire with a non-empty oneof and + // its ids intact. + let client = proto::AgentClientMessage { + exec_client_message: Some(proto::ExecClientMessage { + id: 11, + exec_id: "exec-grep".to_string(), + message: Some(proto::exec_client_message::Message::GrepResult( + proto::GrepResult { + result: Some(proto::grep_result::Result::Error(proto::GrepError { + error: "no".to_string(), + })), + }, + )), + }), + ..Default::default() }; - let messages = build_shell_stream_result( + let decoded = proto::AgentClientMessage::decode(client.encode_to_vec().as_slice()).unwrap(); + let inner = decoded.exec_client_message.expect("exec_client_message"); + assert!(inner.message.is_some(), "empty oneof on the wire"); + assert_eq!(inner.id, 11); + assert_eq!(inner.exec_id, "exec-grep"); + } + + /// Every typed answer is closed on the same id, so the server stops holding + /// the execution open. + #[tokio::test] + async fn every_answer_ends_with_a_stream_close_on_the_same_id() { + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + let run = LiveRun::from_channel_for_test(tx); + let exec = grep_exec(21); + + let sent = answer( &exec, - &CursorNativeToolResult { - content: "error msg".into(), - is_error: true, + ExecAnswer::Result(proto::exec_client_message::Message::GrepResult( + proto::GrepResult { + result: Some(proto::grep_result::Result::Error(proto::GrepError { + error: "no".to_string(), + })), + }, + )), + &run, + ) + .await; + assert!(sent); + + let mut close_id = None; + let mut result_id = None; + while let Ok(Ok(frame)) = rx.try_recv() { + let message = decode_client_frame(&frame); + if let Some(inner) = message.exec_client_message { + result_id = Some(inner.id); + } + if let Some(close) = message + .exec_client_control_message + .and_then(|control| control.message) + && let proto::exec_client_control_message::Message::StreamClose(close) = close + { + close_id = Some(close.id); + } + } + assert_eq!(result_id, Some(21)); + assert_eq!(close_id, Some(21)); + } + + fn grep_exec(id: u32) -> proto::ExecServerMessage { + let mut exec = exec_message(id, "exec-grep"); + exec.grep_args = Some(proto::GrepArgs { + pattern: "TODO".to_string(), + path: None, + tool_call_id: None, + }); + exec + } + + fn decode_client_frame(frame: &bytes::Bytes) -> proto::AgentClientMessage { + let mut decoder = crate::providers::cursor::connect::ConnectFrameDecoder::new(); + let frames = decoder.push(frame).unwrap(); + proto::AgentClientMessage::decode(frames[0].payload.clone()).unwrap() + } + + // ----------------------------------------------------------------------- + // Argument mapping + // ----------------------------------------------------------------------- + + #[test] + fn shell_exec_carries_working_directory_and_timeout_to_bash() { + let mut exec = exec_message(1, "exec-shell"); + exec.shell_stream_args = Some(proto::ShellArgs { + command: "ls".to_string(), + working_directory: Some("/tmp/it's here".to_string()), + timeout: Some(30_000), + tool_call_id: None, + hard_timeout: None, + }); + + match exec_action(&exec, None) { + ExecAction::Call { name, input, kind } => { + assert_eq!(name, "Bash"); + assert_eq!(input["command"], "cd '/tmp/it'\"'\"'s here' && ls"); + assert_eq!(input["timeout"], 30_000); + assert_eq!( + kind, + PendingExecKind::Shell { + command: "ls".to_string(), + cwd: "/tmp/it's here".to_string(), + stream: true, + } + ); + } + _ => panic!("expected a Bash call"), + } + } + + #[test] + fn shell_without_a_working_directory_keeps_the_command_untouched() { + let mut exec = exec_message(2, "exec-shell"); + exec.shell_args = Some(proto::ShellArgs { + command: "pwd".to_string(), + working_directory: None, + timeout: None, + tool_call_id: None, + hard_timeout: None, + }); + + match exec_action(&exec, None) { + ExecAction::Call { input, kind, .. } => { + assert_eq!(input["command"], "pwd"); + assert!(input.get("timeout").is_none()); + assert!(matches!(kind, PendingExecKind::Shell { stream: false, .. })); + } + _ => panic!("expected a Bash call"), + } + } + + #[test] + fn shell_timeout_is_clamped_to_what_bash_accepts() { + let shell = proto::ShellArgs { + command: "sleep 100000".to_string(), + working_directory: None, + timeout: Some(86_400_000), + tool_call_id: None, + hard_timeout: None, + }; + assert_eq!(shell_timeout_ms(&shell), Some(MAX_BASH_TIMEOUT_MS)); + + // Nothing asked for: the fallback is the hard timeout, not a guess. + let shell = proto::ShellArgs { + timeout: Some(0), + hard_timeout: Some(5_000), + ..shell + }; + assert_eq!(shell_timeout_ms(&shell), Some(5_000)); + + let shell = proto::ShellArgs { + timeout: None, + hard_timeout: None, + ..shell + }; + assert_eq!(shell_timeout_ms(&shell), None); + } + + #[test] + fn read_exec_carries_offset_and_limit() { + let mut exec = exec_message(3, "exec-read"); + exec.read_args = Some(proto::ReadArgs { + path: "/tmp/a".to_string(), + tool_call_id: None, + offset: Some(40), + limit: Some(20), + encoding_hint: None, + }); + + match exec_action(&exec, None) { + ExecAction::Call { name, input, .. } => { + assert_eq!(name, "Read"); + assert_eq!(input["file_path"], "/tmp/a"); + assert_eq!(input["offset"], 40); + assert_eq!(input["limit"], 20); + } + _ => panic!("expected a Read call"), + } + + // A negative offset indexes from the end, which Claude Code's Read has + // no form for: dropping it beats reading the wrong window. + if let Some(read) = exec.read_args.as_mut() { + read.offset = Some(-10); + } + match exec_action(&exec, None) { + ExecAction::Call { input, .. } => assert!(input.get("offset").is_none()), + _ => panic!("expected a Read call"), + } + } + + #[test] + fn a_tool_the_client_did_not_advertise_is_rejected_in_band() { + let allowed: BTreeSet = ["Read"].into_iter().map(str::to_string).collect(); + let mut exec = exec_message(4, "exec-shell"); + exec.shell_stream_args = Some(proto::ShellArgs { + command: "rm -rf /".to_string(), + working_directory: None, + timeout: None, + tool_call_id: None, + hard_timeout: None, + }); + + match result_of(exec_action(&exec, Some(&allowed))) { + proto::exec_client_message::Message::ShellResult(result) => match result.result { + Some(proto::shell_result::Result::Failure(failure)) => { + assert_eq!(failure.exit_code, 1); + assert!(failure.stderr.contains("not available")); + } + other => panic!("expected a shell failure, got {other:?}"), }, - std::time::Duration::from_millis(5), - "/tmp", - ); - assert_eq!(messages.len(), 4); - assert_eq!(messages[1]["shellStream"]["stderr"]["data"], "error msg"); - assert_eq!(messages[2]["shellStream"]["exit"]["code"], 1); + other => panic!("expected a shell result, got {other:?}"), + } + } + + #[test] + fn byte_backed_write_carries_its_bytes_as_text() { + let mut exec = exec_message(5, "exec-write"); + exec.write_args = Some(proto::WriteArgs { + path: "/tmp/hi.txt".to_string(), + file_text: String::new(), + tool_call_id: None, + return_file_content_after_write: false, + file_bytes: Some(b"hello\nworld".to_vec()), + encoding_hint: Some("utf8".to_string()), + }); + + match exec_action(&exec, None) { + ExecAction::Call { name, input, kind } => { + assert_eq!(name, "Write"); + assert_eq!(input["content"], "hello\nworld"); + assert_eq!( + kind, + PendingExecKind::Write { + path: "/tmp/hi.txt".to_string(), + content: "hello\nworld".to_string(), + } + ); + } + _ => panic!("expected a Write call"), + } + } + + #[test] + fn non_utf8_write_is_refused_instead_of_writing_an_empty_file() { + let mut exec = exec_message(6, "exec-write"); + exec.write_args = Some(proto::WriteArgs { + path: "/tmp/blob.bin".to_string(), + file_text: String::new(), + tool_call_id: None, + return_file_content_after_write: false, + file_bytes: Some(vec![0xff, 0xfe, 0x00]), + encoding_hint: None, + }); + + match result_of(exec_action(&exec, None)) { + proto::exec_client_message::Message::WriteResult(result) => match result.result { + Some(proto::write_result::Result::Error(error)) => { + assert_eq!(error.path, "/tmp/blob.bin"); + assert!(error.error.contains("not UTF-8")); + } + other => panic!("expected a write error, got {other:?}"), + }, + other => panic!("expected a write result, got {other:?}"), + } + } + + #[test] + fn an_empty_write_still_writes() { + let write = proto::WriteArgs { + path: "/tmp/empty.txt".to_string(), + file_text: String::new(), + tool_call_id: None, + return_file_content_after_write: false, + file_bytes: None, + encoding_hint: None, + }; + assert_eq!(write_content(&write).unwrap(), ""); + } + + #[test] + fn request_context_args_gets_an_empty_success() { + let mut exec = exec_message(0, ""); + exec.id = None; + exec.exec_id = None; + exec.request_context_args = Some(proto::RequestContextArgs { + notes_session_id: Some("975f5819".to_string()), + }); + + match result_of(exec_action(&exec, None)) { + proto::exec_client_message::Message::RequestContextResult(result) => { + assert!(matches!( + result.result, + Some(proto::request_context_result::Result::Success(_)) + )); + } + other => panic!("expected a request context result, got {other:?}"), + } } // ----------------------------------------------------------------------- - // find_tool_result tests + // find_tool_result / advertised tools // ----------------------------------------------------------------------- #[test] @@ -1100,18 +1626,6 @@ mod tests { ); } - #[test] - fn find_tool_result_returns_none_when_not_found() { - let body: MessagesRequest = serde_json::from_value(serde_json::json!({ - "model": "cursor:gpt-5.5", - "messages": [ - {"role": "user", "content": [{"type": "text", "text": "hello"}]} - ] - })) - .unwrap(); - assert!(find_tool_result(&body, "call_1").is_none()); - } - #[test] fn find_tool_result_scans_newest_first() { let body: MessagesRequest = serde_json::from_value(serde_json::json!({ @@ -1123,18 +1637,15 @@ mod tests { ] })) .unwrap(); - let result = find_tool_result(&body, "call_1"); - assert!(result.is_some()); assert_eq!( - result.unwrap().get("content").and_then(|c| c.as_str()), + find_tool_result(&body, "call_1") + .unwrap() + .get("content") + .and_then(|c| c.as_str()), Some("new") ); } - // ----------------------------------------------------------------------- - // advertised_tool_names tests - // ----------------------------------------------------------------------- - #[test] fn advertised_tool_names_extracts_read_write_bash() { let body: MessagesRequest = serde_json::from_value(serde_json::json!({ @@ -1154,17 +1665,7 @@ mod tests { } #[test] - fn advertised_tool_names_no_tools_returns_none() { - let body: MessagesRequest = serde_json::from_value(serde_json::json!({ - "model": "cursor:gpt-5.5", - "messages": [{"role": "user", "content": "hi"}] - })) - .unwrap(); - assert!(advertised_tool_names(&body).is_none()); - } - - #[test] - fn can_bridge_returns_true_for_stream_with_read_tool() { + fn can_bridge_needs_stream_session_and_a_tool() { let body: MessagesRequest = serde_json::from_value(serde_json::json!({ "model": "cursor:gpt-5.5", "stream": true, @@ -1173,141 +1674,113 @@ mod tests { })) .unwrap(); assert!(can_bridge_cursor_native_tools(&body, Some("session-1"))); - } - - #[test] - fn can_bridge_returns_false_for_non_streaming() { - let body: MessagesRequest = serde_json::from_value(serde_json::json!({ - "model": "cursor:gpt-5.5", - "stream": false, - "messages": [{"role": "user", "content": "hi"}], - "tools": [{"name": "Read", "description": "read", "input_schema": {}}] - })) - .unwrap(); - assert!(!can_bridge_cursor_native_tools(&body, Some("session-1"))); - } - - #[test] - fn can_bridge_returns_false_without_session_id() { - let body: MessagesRequest = serde_json::from_value(serde_json::json!({ - "model": "cursor:gpt-5.5", - "stream": true, - "messages": [{"role": "user", "content": "hi"}], - "tools": [{"name": "Read", "description": "read", "input_schema": {}}] - })) - .unwrap(); assert!(!can_bridge_cursor_native_tools(&body, None)); assert!(!can_bridge_cursor_native_tools(&body, Some(""))); + + let mut non_stream = body.clone(); + non_stream.stream = false; + assert!(!can_bridge_cursor_native_tools( + &non_stream, + Some("session-1") + )); } // ----------------------------------------------------------------------- - // BridgeRegistry tests + // Registry lifecycle // ----------------------------------------------------------------------- - #[test] - fn bridge_registry_manages_sessions() { + /// A new run for a session replaces the old entry instead of hiding behind + /// it, a finished turn removes its own entry, and a run nobody came back + /// for expires by age. + #[tokio::test] + async fn bridge_registry_never_keeps_a_stale_entry() { let _lock = REGISTRY_LOCK.lock().unwrap(); BridgeRegistry::clear(); assert_eq!(BridgeRegistry::active_count(), 0); - let state = CursorBridgeState::new( - "session-test".into(), - "msg-1".into(), - "cursor-test".into(), - None, - Box::new(|| "id".into()), - ); - BridgeRegistry::insert(state); + insert_bridge("session-a", Some("toolu_old")); assert_eq!(BridgeRegistry::active_count(), 1); - assert!(BridgeRegistry::get("session-test").is_some()); - - let state = BridgeRegistry::take("session-test"); - assert!(state.is_some()); - assert_eq!(BridgeRegistry::active_count(), 0); - } - - #[test] - fn bridge_registry_set_and_get_pending_tool() { - let _lock = REGISTRY_LOCK.lock().unwrap(); - BridgeRegistry::clear(); - let state = CursorBridgeState::new( - "session-pt".into(), - "msg-1".into(), - "cursor-test".into(), - None, - Box::new(|| "id".into()), + insert_bridge("session-a", None); + assert_eq!(BridgeRegistry::active_count(), 1); + assert!( + BridgeRegistry::take("session-a").unwrap().pending.is_none(), + "a new run replaces the old entry" ); - BridgeRegistry::insert(state); + assert_eq!(BridgeRegistry::active_count(), 0); - let tool = PendingCursorTool::Read { - tool_use_id: "call_1".into(), - path: "/tmp/a".into(), - }; - BridgeRegistry::set_pending_tool("session-pt", tool); + // Taking is what removes a finished turn's run. + insert_bridge("session-b", Some("toolu_1")); + assert!(BridgeRegistry::take("session-b").is_some()); + assert_eq!(BridgeRegistry::active_count(), 0); - let retrieved = BridgeRegistry::pending_tool("session-pt"); - assert!(retrieved.is_some()); - assert_eq!(retrieved.unwrap().name(), "Read"); + // A run nobody returns for expires by age. + insert_bridge("session-c", None); + assert_eq!(BridgeRegistry::sweep(Duration::from_secs(600)), 0); + assert_eq!(BridgeRegistry::active_count(), 1); + assert_eq!(BridgeRegistry::sweep(Duration::from_secs(0)), 1); + assert_eq!(BridgeRegistry::active_count(), 0); BridgeRegistry::clear(); } - // ----------------------------------------------------------------------- - // render_tool_result_content tests - // ----------------------------------------------------------------------- + /// A parked run must never block the next exec: the caller resumes only + /// when the request carries the matching tool_result, and drops the run + /// otherwise, so the request's own exec runs. + #[tokio::test] + async fn a_run_without_a_pending_exec_does_not_block_the_next_exec() { + let _lock = REGISTRY_LOCK.lock().unwrap(); + BridgeRegistry::clear(); + insert_bridge("session-d", None); - #[test] - fn renders_string_content() { - let result = serde_json::json!({ - "type": "tool_result", - "content": "plain string" - }); - assert_eq!(render_tool_result_content(&result), "plain string"); - } + let exec = read_exec(1, "exec-1", "/tmp/a"); + let parked = BridgeRegistry::take("session-d").expect("parked run"); + assert!(parked.pending.is_none()); + assert!( + matches!(exec_action(&exec, None), ExecAction::Call { .. }), + "the request's own exec still reaches Claude Code" + ); + assert_eq!(BridgeRegistry::active_count(), 0); - #[test] - fn renders_array_content() { - let result = serde_json::json!({ - "type": "tool_result", - "content": [ - {"type": "text", "text": "part one"}, - {"type": "text", "text": "part two"} - ] - }); - let rendered = render_tool_result_content(&result); - assert!(rendered.contains("part one")); - assert!(rendered.contains("part two")); + BridgeRegistry::clear(); } - #[test] - fn renders_mixed_content_types() { - let result = serde_json::json!({ - "type": "tool_result", - "content": [ - {"type": "text", "text": "text result"}, - {"type": "image", "source": {"type": "base64", "data": "AAAA"}} - ] + /// Insert a run backed by an in-memory channel: enough for the registry, + /// with no HTTP stream to leak in a test. + fn insert_bridge(session_id: &str, pending: Option<&str>) { + let (_events_tx, events) = tokio::sync::mpsc::channel(1); + let run = LiveRun::for_test(events); + let mut bridge = LiveBridge::new(session_id.to_string(), run, None); + bridge.pending = pending.map(|tool_use_id| PendingExec { + tool_use_id: tool_use_id.to_string(), + id: 1, + exec_id: "exec-1".to_string(), + kind: PendingExecKind::Read { + path: "/tmp/a".to_string(), + }, }); - let rendered = render_tool_result_content(&result); - assert!(rendered.contains("text result")); - assert!(rendered.contains("[image result omitted]")); - } - - #[test] - fn render_empty_tool_result() { - let result = serde_json::json!({"type": "tool_result"}); - assert_eq!(render_tool_result_content(&result), ""); + BridgeRegistry::insert(bridge); } #[test] - fn detects_error_from_tool_result() { - let result = serde_json::json!({"type": "tool_result", "is_error": true}); - assert!(tool_result_is_error(&result)); - - let result = serde_json::json!({"type": "tool_result", "is_error": false}); - assert!(!tool_result_is_error(&result)); - - let result = serde_json::json!({"type": "tool_result"}); - assert!(!tool_result_is_error(&result)); + fn read_result_gives_cursor_the_file_without_line_numbers() { + assert_eq!(raw_file_text("1\talpha\n2\t"), "alpha\n"); + assert_eq!( + raw_file_text(" 1\tfn a() {}\n 2\t\tx\n"), + "fn a() {}\n\tx" + ); + assert_eq!( + raw_file_text("1\tline\n\n\nnote\n"), + "line" + ); + assert_eq!( + raw_file_text( + "Warning: the file exists but the contents are empty." + ), + "" + ); + assert_eq!( + raw_file_text("plain text, not numbered"), + "plain text, not numbered" + ); } } diff --git a/tests/cursor_native.rs b/tests/cursor_native.rs index 57aed4d9..14393f6f 100644 --- a/tests/cursor_native.rs +++ b/tests/cursor_native.rs @@ -13,7 +13,7 @@ use http_body_util::BodyExt; use once_cell::sync::Lazy; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; static ENV_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); @@ -40,6 +40,8 @@ fn prost_roundtrip_preserves_cursor_server_message() { }), }), exec_server_message: None, + conversation_checkpoint_update: None, + kv_server_message: None, }; let mut bytes = Vec::new(); msg.encode(&mut bytes).unwrap(); @@ -82,6 +84,9 @@ fn prost_roundtrip_preserves_client_message() { conversation_group_id: String::new(), client_supports_inline_images: true, }), + exec_client_message: None, + kv_client_message: None, + exec_client_control_message: None, client_heartbeat: None, }; @@ -292,6 +297,9 @@ async fn cursor_client_sends_connect_proto_headers_and_run_request_frame() { }), }), exec_server_message: None, + + conversation_checkpoint_update: None, + kv_server_message: None, }; let mut payload = Vec::new(); msg.encode(&mut payload).unwrap(); @@ -475,6 +483,8 @@ fn response_decode_extracts_text_and_usage() { turn_ended: None, }), exec_server_message: None, + conversation_checkpoint_update: None, + kv_server_message: None, }; let mut payload = Vec::new(); msg.encode(&mut payload).unwrap(); @@ -493,6 +503,8 @@ fn response_decode_extracts_text_and_usage() { }), }), exec_server_message: None, + conversation_checkpoint_update: None, + kv_server_message: None, }; let mut payload = Vec::new(); msg.encode(&mut payload).unwrap(); @@ -534,6 +546,8 @@ fn sse_parses_event_names_and_data() { turn_ended: None, }), exec_server_message: None, + conversation_checkpoint_update: None, + kv_server_message: None, }; let mut payload = Vec::new(); msg.encode(&mut payload).unwrap(); @@ -551,6 +565,8 @@ fn sse_parses_event_names_and_data() { }), }), exec_server_message: None, + conversation_checkpoint_update: None, + kv_server_message: None, }; let mut payload = Vec::new(); msg.encode(&mut payload).unwrap(); @@ -610,6 +626,8 @@ fn sse_message_delta_contains_usage() { }), }), exec_server_message: None, + conversation_checkpoint_update: None, + kv_server_message: None, }; let mut payload = Vec::new(); msg.encode(&mut payload).unwrap(); @@ -703,6 +721,8 @@ async fn cursor_provider_streams_text_and_usage_from_mock_upstream() { turn_ended: None, }), exec_server_message: None, + conversation_checkpoint_update: None, + kv_server_message: None, }; let mut payload = Vec::new(); msg.encode(&mut payload).unwrap(); @@ -720,6 +740,8 @@ async fn cursor_provider_streams_text_and_usage_from_mock_upstream() { }), }), exec_server_message: None, + conversation_checkpoint_update: None, + kv_server_message: None, }; let mut payload = Vec::new(); msg.encode(&mut payload).unwrap(); @@ -827,6 +849,8 @@ async fn cursor_provider_handle_messages_returns_anthropic_json() { }), }), exec_server_message: None, + conversation_checkpoint_update: None, + kv_server_message: None, }; let mut payload = Vec::new(); msg.encode(&mut payload).unwrap(); @@ -931,6 +955,9 @@ async fn cursor_proxy_http_path_reaches_mock_cursor_upstream() { turn_ended: None, }), exec_server_message: None, + + conversation_checkpoint_update: None, + kv_server_message: None, }; let mut text_payload = Vec::new(); text_msg.encode(&mut text_payload).unwrap(); @@ -947,6 +974,9 @@ async fn cursor_proxy_http_path_reaches_mock_cursor_upstream() { }), }), exec_server_message: None, + + conversation_checkpoint_update: None, + kv_server_message: None, }; let mut usage_payload = Vec::new(); usage_msg.encode(&mut usage_payload).unwrap(); @@ -1067,435 +1097,366 @@ async fn cursor_proxy_http_path_reaches_mock_cursor_upstream() { } // --------------------------------------------------------------------------- -// Cursor tool bridge integration tests +// Live run: one stream across a tool round trip // --------------------------------------------------------------------------- -#[test] -fn bridge_start_pauses_on_tool_use_xml() { - use claude_code_proxy::providers::cursor::response::*; - use claude_code_proxy::providers::cursor::tool_bridge::*; - - // Create upstream events with a text delta containing XML tool_use - let events = vec![ - CursorStreamEvent::TextDelta { - text: "before ".to_string(), - }, - CursorStreamEvent::TextDelta { - text: r#"{"file_path":"/tmp/a"}"#.to_string(), - }, - CursorStreamEvent::TextDelta { - text: " after".to_string(), - }, - CursorStreamEvent::Usage { - input_tokens: 10, - output_tokens: 5, - cache_read_tokens: 0, - cache_write_tokens: 0, - }, - CursorStreamEvent::End, - ]; - - let allowed: std::collections::BTreeSet = - ["Read".to_string(), "Write".to_string(), "Bash".to_string()] - .into_iter() - .collect(); - - let mut counter = 0u64; - let id_factory = Box::new(move || { - counter += 1; - format!("call_cursor_test_{counter}") - }); - - let (sse, paused) = start_cursor_tool_bridge( - "msg_1", - "cursor-test", - "session-bridge-1", - &events, - Some(allowed), - id_factory, - ); - - assert!(paused, "bridge should pause on tool_use"); - - let sse_str = String::from_utf8_lossy(&sse); - let parsed = parse_sse_events(&sse_str); - - let event_names: Vec<&str> = parsed.iter().map(|(n, _)| n.as_str()).collect(); - assert!( - event_names.contains(&"content_block_start"), - "expected content_block_start for tool_use" - ); - assert!( - event_names.contains(&"message_stop"), - "expected message_stop" - ); - - let msg_delta = parsed - .iter() - .find(|(n, _)| n == "message_delta") - .map(|(_, d)| d.clone()); - assert!(msg_delta.is_some(), "expected message_delta"); - assert_eq!( - msg_delta.unwrap()["delta"]["stop_reason"], - "tool_use", - "stop_reason should be tool_use" - ); - - // Clean up - BridgeRegistry::remove("session-bridge-1"); -} +/// The regression this bridge exists for: after Claude Code runs the tool, the +/// result has to travel back on the run that asked for it. A second `Run` makes +/// the agent re-plan from scratch and re-issue the same call. +#[tokio::test] +async fn live_run_answers_the_tool_result_on_the_same_stream() { + use axum::Router; + use axum::body::Body; + use axum::routing::post; + use claude_code_proxy::providers::cursor::connect::{ + ConnectFrameDecoder, encode_connect_frame, + }; + use claude_code_proxy::providers::cursor::proto::*; + use futures_util::StreamExt; + use prost::Message; + use std::sync::atomic::{AtomicUsize, Ordering}; -#[test] -fn bridge_start_passes_through_without_tool_use() { - use claude_code_proxy::providers::cursor::response::*; - use claude_code_proxy::providers::cursor::tool_bridge::*; - - let events = vec![ - CursorStreamEvent::TextDelta { - text: "hello world".to_string(), - }, - CursorStreamEvent::Usage { - input_tokens: 5, - output_tokens: 1, - cache_read_tokens: 0, - cache_write_tokens: 0, - }, - CursorStreamEvent::End, - ]; - - let (sse, paused) = start_cursor_tool_bridge( - "msg_2", - "cursor-test", - "session-bridge-2", - &events, - None, - Box::new(|| "id".into()), - ); + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let runs = Arc::new(AtomicUsize::new(0)); + let replies: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let app = { + let runs = Arc::clone(&runs); + let replies = Arc::clone(&replies); + Router::new().route( + "/agent.v1.AgentService/Run", + post(move |body: Body| async move { + runs.fetch_add(1, Ordering::SeqCst); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let replies = Arc::clone(&replies); + + tokio::spawn(async move { + let mut decoder = ConnectFrameDecoder::new(); + let mut stream = body.into_data_stream(); + let mut asked = false; + let mut answered = false; + + while let Some(Ok(chunk)) = stream.next().await { + let frames = match decoder.push(&chunk) { + Ok(frames) => frames, + Err(_) => break, + }; + if !asked && !frames.is_empty() { + // The run request has landed: ask for a tool. + asked = true; + let _ = tx.send(Ok(exec_frame())); + } + for frame in frames { + let message = match AgentClientMessage::decode(frame.payload.clone()) { + Ok(message) => message, + Err(_) => continue, + }; + let Some(exec) = message.exec_client_message else { + continue; + }; + replies.lock().unwrap().push(exec); + if !answered { + answered = true; + // The agent carries on with the result it got. + let _ = tx.send(Ok(text_frame("the tool returned grok-ok"))); + let _ = tx.send(Ok(usage_frame(21, 7))); + let _ = tx.send(Ok(encode_connect_frame(b"", 2))); + } + } + } + }); + + let body = + Body::from_stream(futures_util::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|item| { + ( + item.map_err(|error: std::io::Error| { + std::io::Error::other(error.to_string()) + }), + rx, + ) + }) + })); + ( + [( + axum::http::header::CONTENT_TYPE, + "application/connect+proto", + )], + body, + ) + }), + ) + }; - assert!(!paused, "bridge should NOT pause without tool_use"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let _handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); - let sse_str = String::from_utf8_lossy(&sse); - let parsed = parse_sse_events(&sse_str); - let event_names: Vec<&str> = parsed.iter().map(|(n, _)| n.as_str()).collect(); + unsafe { + std::env::set_var("CCP_CURSOR_BASE_URL", format!("http://{addr}")); + std::env::set_var("CCP_CURSOR_AUTH_TOKEN", "proxy-token"); + } - assert!(event_names.contains(&"message_start")); - assert!(event_names.contains(&"content_block_delta")); - assert!(event_names.contains(&"message_delta")); - assert!(event_names.contains(&"message_stop")); + let provider = claude_code_proxy::providers::cursor::CursorProvider::new(); + let tools = serde_json::json!([ + {"name": "Bash", "description": "run", "input_schema": {"type": "object"}}, + {"name": "Read", "description": "read", "input_schema": {"type": "object"}}, + {"name": "Write", "description": "write", "input_schema": {"type": "object"}} + ]); - // Verify stop_reason is end_turn - let msg_delta = parsed + // Turn 1: the agent asks for a read. + let first = serde_json::json!({ + "model": "cursor:gpt-5.5", + "stream": true, + "tools": tools, + "messages": [{"role": "user", "content": "read /etc/hostname"}] + }); + let first_sse = collect_sse(&provider, first, "probe-loop-1").await; + let first_events = parse_sse_events(&first_sse); + let tool_use_id = first_events .iter() - .find(|(n, _)| n == "message_delta") - .map(|(_, d)| d.clone()); + .find_map(|(name, data)| { + (name == "content_block_start" && data["content_block"]["type"] == "tool_use").then( + || { + data["content_block"]["id"] + .as_str() + .unwrap_or("") + .to_string() + }, + ) + }) + .expect("turn 1 asks for a tool"); assert_eq!( - msg_delta.unwrap()["delta"]["stop_reason"], - "end_turn", - "stop_reason should be end_turn without tool_use" - ); -} - -#[test] -fn bridge_start_creates_pending_tool_in_registry() { - use claude_code_proxy::providers::cursor::response::*; - use claude_code_proxy::providers::cursor::tool_bridge::*; - - // Clean state - BridgeRegistry::clear(); - - let events = vec![CursorStreamEvent::TextDelta { - text: r#"{"file_path":"/tmp/test"}"#.to_string(), - }]; - - let allowed: std::collections::BTreeSet = ["Read".to_string()].into_iter().collect(); - - let (_, paused) = start_cursor_tool_bridge( - "msg_3", - "cursor-test", - "session-bridge-pt", - &events, - Some(allowed), - Box::new(|| "call_test".into()), + first_events + .iter() + .find(|(name, _)| name == "message_delta") + .map(|(_, data)| data["delta"]["stop_reason"].clone()), + Some(serde_json::json!("tool_use")) ); - assert!(paused); - - let pending = BridgeRegistry::pending_tool("session-bridge-pt"); - assert!(pending.is_some(), "pending tool should be stored"); - assert_eq!(pending.unwrap().name(), "Read"); - - BridgeRegistry::remove("session-bridge-pt"); -} - -#[test] -fn bridge_resume_continues_after_tool_use_pause() { - use claude_code_proxy::providers::cursor::response::*; - use claude_code_proxy::providers::cursor::tool_bridge::*; - - BridgeRegistry::clear(); - - // Events: tool_use in the middle, text after - let events = vec![ - CursorStreamEvent::TextDelta { - text: "before ".to_string(), - }, - CursorStreamEvent::TextDelta { - text: r#"{"file_path":"/tmp/a"}"#.to_string(), - }, - CursorStreamEvent::TextDelta { - text: " continued".to_string(), - }, - CursorStreamEvent::Usage { - input_tokens: 10, - output_tokens: 5, - cache_read_tokens: 0, - cache_write_tokens: 0, - }, - CursorStreamEvent::End, - ]; - - let allowed: std::collections::BTreeSet = ["Read".to_string()].into_iter().collect(); - - let mut counter = 0u64; - let id_factory = Box::new(move || { - counter += 1; - format!("call_cursor_test_{counter}") + // Turn 2: Claude Code answers with the tool result. + let second = serde_json::json!({ + "model": "cursor:gpt-5.5", + "stream": true, + "tools": tools, + "messages": [ + {"role": "user", "content": "read /etc/hostname"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": tool_use_id, "name": "Read", + "input": {"file_path": "/etc/hostname"}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": tool_use_id, "content": "grok-ok"} + ]} + ] }); + let second_sse = collect_sse(&provider, second, "probe-loop-1").await; + let second_events = parse_sse_events(&second_sse); - let (_first_sse, paused) = start_cursor_tool_bridge( - "msg_first", - "cursor-test", - "session-resume-1", - &events, - Some(allowed), - id_factory, - ); - assert!(paused); - - let body: claude_code_proxy::MessagesRequest = - serde_json::from_value(serde_json::json!({ - "model": "cursor-test", - "messages": [ - {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_cursor_test_1", "content": "result text"}]} - ] - })) - .unwrap(); - - let pending = - BridgeRegistry::pending_tool("session-resume-1").expect("should have pending tool"); - assert_eq!(pending.tool_use_id(), "call_cursor_test_1"); - - let result = find_tool_result(&body, pending.tool_use_id()).expect("should find tool result"); - - let (result_msgs, second_sse) = resume_cursor_tool_bridge( - "session-resume-1", - "msg_second", - "cursor-test", - result, - &pending, - ); - - assert!(!result_msgs.is_empty(), "should have result messages"); - - let sse_str = String::from_utf8_lossy(&second_sse); - let parsed = parse_sse_events(&sse_str); - let event_names: Vec<&str> = parsed.iter().map(|(n, _)| n.as_str()).collect(); - + let text: String = second_events + .iter() + .filter(|(name, _)| name == "content_block_delta") + .filter_map(|(_, data)| data["delta"]["text"].as_str()) + .collect(); assert!( - event_names.contains(&"message_start"), - "resume should have message_start in {event_names:?}" + text.contains("the tool returned grok-ok"), + "turn 2 must carry the final answer, got {second_sse}" ); assert!( - event_names.contains(&"message_stop"), - "resume should have message_stop in {event_names:?}" + !second_events + .iter() + .any(|(name, data)| name == "content_block_start" + && data["content_block"]["type"] == "tool_use"), + "turn 2 re-issued the tool call instead of using the result: {second_sse}" ); - let text_deltas: Vec<&str> = parsed - .iter() - .filter_map(|(n, d)| { - if n == "content_block_delta" { - d["delta"]["text"].as_str() - } else { - None - } - }) - .collect(); - let combined = text_deltas.join(""); - assert!( - combined.contains("continued"), - "resume should include remaining text deltas" - ); + // The whole round trip happened on ONE run, and the reply carried the exec + // ids the agent gave us. + assert_eq!(runs.load(Ordering::SeqCst), 1, "a second Run was started"); + let replies = replies.lock().unwrap().clone(); + assert_eq!(replies.len(), 1, "one exec reply"); + assert_eq!(replies[0].id, 1); + assert_eq!(replies[0].exec_id, "exec-1"); + assert!(matches!( + replies[0].message, + Some(exec_client_message::Message::ReadResult(_)) + )); - BridgeRegistry::remove("session-resume-1"); + unsafe { + std::env::remove_var("CCP_CURSOR_BASE_URL"); + std::env::remove_var("CCP_CURSOR_AUTH_TOKEN"); + } } -#[test] -fn bridge_rejects_tool_not_in_allowed_list() { - use claude_code_proxy::providers::cursor::response::*; - use claude_code_proxy::providers::cursor::tool_bridge::*; - - BridgeRegistry::clear(); - - let events = vec![CursorStreamEvent::TextDelta { - text: r#"{"command":"pwd"}"#.to_string(), - }]; +/// A text-only turn still ends on the upstream's own end frame. +#[tokio::test] +async fn live_run_still_answers_plain_turns() { + use axum::Router; + use axum::routing::post; + use claude_code_proxy::providers::cursor::connect::encode_connect_frame; + use std::sync::atomic::{AtomicUsize, Ordering}; - let allowed: std::collections::BTreeSet = ["Read".to_string()].into_iter().collect(); + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let runs = Arc::new(AtomicUsize::new(0)); + + let app = { + let runs = Arc::clone(&runs); + Router::new().route( + "/agent.v1.AgentService/Run", + post(move |_body: axum::body::Body| async move { + runs.fetch_add(1, Ordering::SeqCst); + let mut body = text_frame("plain answer").to_vec(); + body.extend_from_slice(&usage_frame(9, 2)); + body.extend_from_slice(&encode_connect_frame(b"", 2)); + ( + [( + axum::http::header::CONTENT_TYPE, + "application/connect+proto", + )], + body, + ) + }), + ) + }; - let (sse, paused) = start_cursor_tool_bridge( - "msg_filter", - "cursor-test", - "session-filter-1", - &events, - Some(allowed), - Box::new(|| "id".into()), - ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let _handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); - assert!(!paused, "should NOT pause for disallowed tool"); + unsafe { + std::env::set_var("CCP_CURSOR_BASE_URL", format!("http://{addr}")); + std::env::set_var("CCP_CURSOR_AUTH_TOKEN", "proxy-token"); + } - let sse_str = String::from_utf8_lossy(&sse); - let parsed = parse_sse_events(&sse_str); - let _event_names: Vec<&str> = parsed.iter().map(|(n, _)| n.as_str()).collect(); + let provider = claude_code_proxy::providers::cursor::CursorProvider::new(); + let sse = collect_sse( + &provider, + serde_json::json!({ + "model": "cursor:gpt-5.5", + "stream": true, + "tools": [{"name": "Read", "description": "read", "input_schema": {}}], + "messages": [{"role": "user", "content": "hi"}] + }), + "probe-plain-1", + ) + .await; - let msg_delta = parsed + let events = parse_sse_events(&sse); + let text: String = events .iter() - .find(|(n, _)| n == "message_delta") - .map(|(_, d)| d.clone()); - assert_eq!( - msg_delta.unwrap()["delta"]["stop_reason"], - "end_turn", - "disallowed tool should not trigger tool_use" - ); + .filter(|(name, _)| name == "content_block_delta") + .filter_map(|(_, data)| data["delta"]["text"].as_str()) + .collect(); + assert_eq!(text, "plain answer"); + assert!(events.iter().any(|(name, _)| name == "message_stop")); + assert_eq!(runs.load(Ordering::SeqCst), 1); - BridgeRegistry::remove("session-filter-1"); + unsafe { + std::env::remove_var("CCP_CURSOR_BASE_URL"); + std::env::remove_var("CCP_CURSOR_AUTH_TOKEN"); + } } -#[test] -fn bridge_result_messages_have_correct_read_shape() { - use claude_code_proxy::providers::cursor::tool_bridge::*; - - let exec = CursorExec { - id: Some(42), - exec_id: None, - args: serde_json::json!({"file_path": "/tmp/readme.txt"}), - }; - let result = CursorNativeToolResult { - content: "file contents here".into(), - is_error: false, - }; - - let msg = build_read_result_from_native(&exec, &result); - let msg_obj = msg.as_object().unwrap(); - - assert_eq!(msg_obj.get("id").and_then(|v| v.as_i64()), Some(42)); - - let read_result = msg_obj.get("readResult").unwrap(); - assert!(read_result.get("success").is_some()); - let success = read_result.get("success").unwrap(); - assert_eq!( - success.get("path").and_then(|v| v.as_str()), - Some("/tmp/readme.txt") - ); - assert_eq!( - success.get("content").and_then(|v| v.as_str()), - Some("file contents here") - ); - assert_eq!(success.get("totalLines").and_then(|v| v.as_i64()), Some(1)); +/// Call the provider the way the proxy does and collect the whole SSE body. +async fn collect_sse( + provider: &claude_code_proxy::providers::cursor::CursorProvider, + body: serde_json::Value, + session_id: &str, +) -> String { + use claude_code_proxy::provider::{Provider, RequestContext}; + + let request: claude_code_proxy::MessagesRequest = serde_json::from_value(body).unwrap(); + let response = provider + .handle_messages( + request, + RequestContext { + req_id: format!("test-{session_id}"), + session_id: Some(session_id.to_string()), + session_seq: None, + provider: "cursor".to_string(), + traffic: None, + monitor: None, + }, + ) + .await; + let bytes = response + .into_body() + .collect() + .await + .expect("collect sse") + .to_bytes(); + String::from_utf8_lossy(&bytes).to_string() } -#[test] -fn bridge_result_messages_have_correct_write_shape() { - use claude_code_proxy::providers::cursor::tool_bridge::*; - - let exec = CursorExec { - id: Some(99), - exec_id: Some("exec-write-1".into()), - args: serde_json::json!({"file_path": "/tmp/writeme.txt", "content": "data"}), - }; +/// The frame the agent asks a tool in: AgentServerMessage tag 2 -> +/// ExecServerMessage id 1, read_args tag 7, exec_id tag 15. +fn exec_frame() -> bytes::Bytes { + use claude_code_proxy::providers::cursor::connect::encode_connect_frame; + use claude_code_proxy::providers::cursor::proto::*; + use prost::Message; - let result = CursorNativeToolResult { - content: "written".into(), - is_error: false, - }; - let msg = build_write_result_from_native(&exec, &result); - let msg_obj = msg.as_object().unwrap(); - assert_eq!( - msg_obj.get("execId").and_then(|v| v.as_str()), - Some("exec-write-1") - ); - let write_result = msg_obj.get("writeResult").unwrap(); - let success = write_result.get("success").unwrap(); - assert_eq!( - success.get("path").and_then(|v| v.as_str()), - Some("/tmp/writeme.txt") - ); - assert!(success.get("linesCreated").is_some()); - assert!(success.get("fileSize").is_some()); + let message = AgentServerMessage { + interaction_update: None, + exec_server_message: Some(ExecServerMessage { + id: Some(1), + exec_id: Some("exec-1".to_string()), + read_args: Some(ReadArgs { + path: "/etc/hostname".to_string(), + tool_call_id: Some("tool_abc".to_string()), + ..Default::default() + }), + ..Default::default() + }), - let error_result = CursorNativeToolResult { - content: "permission denied".into(), - is_error: true, + conversation_checkpoint_update: None, + kv_server_message: None, }; - let err_msg = build_write_result_from_native(&exec, &error_result); - let err_obj = err_msg.as_object().unwrap(); - let write_result = err_obj.get("writeResult").unwrap(); - let error = write_result.get("error").unwrap(); - assert_eq!( - error.get("path").and_then(|v| v.as_str()), - Some("/tmp/writeme.txt") - ); - assert!( - error - .get("error") - .and_then(|v| v.as_str()) - .unwrap_or("") - .contains("permission") - ); + encode_connect_frame(message.encode_to_vec(), 0) } -#[test] -fn bridge_shell_stream_result_has_correct_shape() { - use claude_code_proxy::providers::cursor::tool_bridge::*; - - let exec = CursorExec { - id: Some(7), - exec_id: Some("exec-shell".into()), - args: serde_json::json!({}), - }; +fn text_frame(text: &str) -> bytes::Bytes { + use claude_code_proxy::providers::cursor::connect::encode_connect_frame; + use claude_code_proxy::providers::cursor::proto::*; + use prost::Message; - let result = CursorNativeToolResult { - content: "stdout output".into(), - is_error: false, + let message = AgentServerMessage { + interaction_update: Some(InteractionUpdate { + text_delta: Some(TextDelta { + text: text.to_string(), + }), + ..Default::default() + }), + exec_server_message: None, + conversation_checkpoint_update: None, + kv_server_message: None, }; + encode_connect_frame(message.encode_to_vec(), 0) +} - let messages = build_shell_stream_result( - &exec, - &result, - std::time::Duration::from_millis(150), - "/home/user", - ); - - assert_eq!(messages.len(), 4, "start + stdout + exit + close"); - - assert!( - messages[0] - .get("shellStream") - .and_then(|s| s.get("start")) - .is_some() - ); - - assert_eq!( - messages[1]["shellStream"]["stdout"]["data"], - "stdout output" - ); - - assert_eq!(messages[2]["shellStream"]["exit"]["code"], 0); - assert_eq!(messages[2]["shellStream"]["exit"]["cwd"], "/home/user"); +fn usage_frame(input: u64, output: u64) -> bytes::Bytes { + use claude_code_proxy::providers::cursor::connect::encode_connect_frame; + use claude_code_proxy::providers::cursor::proto::*; + use prost::Message; - assert_eq!( - messages[3]["execClientControlMessage"]["streamClose"]["id"], - 7 - ); + let message = AgentServerMessage { + interaction_update: Some(InteractionUpdate { + turn_ended: Some(TurnEnded { + input_tokens: input, + output_tokens: output, + cache_read_tokens: 0, + cache_write_tokens: 0, + }), + ..Default::default() + }), + exec_server_message: None, + conversation_checkpoint_update: None, + kv_server_message: None, + }; + encode_connect_frame(message.encode_to_vec(), 0) } // ---------------------------------------------------------------------------