chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "jcode-provider-cursor-runtime"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Cursor provider runtime (direct ChatService streaming) for jcode, kept downstream of jcode-base so provider edits do not rebuild the app spine"
|
||||
|
||||
[lib]
|
||||
name = "jcode_provider_cursor_runtime"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
bytes = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
flate2 = "1"
|
||||
h2 = "0.4"
|
||||
http = "1"
|
||||
# default-features = false: the top-level binary decides heavy optional base
|
||||
# features (embeddings/bedrock). Runtime crates must not re-enable them via
|
||||
# feature unification, or --no-default-features release targets (e.g. Windows
|
||||
# ARM64, which cannot build tract-linalg asm) break.
|
||||
jcode-base = { path = "../jcode-base", default-features = false }
|
||||
jcode-message-types = { path = "../jcode-message-types" }
|
||||
jcode-provider-core = { path = "../jcode-provider-core" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["sync", "time", "rt", "process", "io-util", "net"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
|
||||
tokio-stream = "0.1"
|
||||
uuid = { version = "1", features = ["v4", "v5"] }
|
||||
webpki-roots = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
# The migrated cursor tests use jcode-base's test-env sandbox (lock_test_env).
|
||||
jcode-base = { path = "../jcode-base", features = ["test-support"] }
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,572 @@
|
||||
//! Native Cursor Agent transport implementing `agent.v1.AgentService/Run`.
|
||||
//!
|
||||
//! Cursor decommissioned the old `api2.cursor.sh/aiserver.v1.ChatService/
|
||||
//! StreamUnifiedChatWithTools` endpoint for API-key / CLI tokens (it now returns
|
||||
//! `resource_exhausted` "Update Required" / `actionRequired: payment`). The
|
||||
//! current, working transport used by the `cursor-agent` CLI is a *paced,
|
||||
//! bidirectional* Connect-over-HTTP/2 stream against
|
||||
//! `agentn.global.api5.cursor.sh/agent.v1.AgentService/Run`.
|
||||
//!
|
||||
//! Wire format (reverse-engineered by MITM-capturing the real `cursor-agent`):
|
||||
//!
|
||||
//! * Connect streaming framing: each message is `[1 flag byte][4-byte BE len]
|
||||
//! [payload]`. Flag `0x01` = payload gzip-compressed, `0x02` = end-of-stream
|
||||
//! trailer (JSON, `{}` on success or `{"error":...}`).
|
||||
//! * The logical `RunInput` is split across several request frames, each
|
||||
//! carrying a different top-level protobuf field:
|
||||
//! - frame 0 = field 1 (`RunRequest`: prompt, model, model catalog),
|
||||
//! - frame 1 = field 2 (environment/tool context),
|
||||
//! - then a short sequence of small field-3/5/7 marker frames.
|
||||
//! * The client keeps the request stream **open** while reading the response,
|
||||
//! emitting periodic `f7:''` heartbeats (~5s) and pacing marker frames as the
|
||||
//! server streams, half-closing only after the server completes. Sending the
|
||||
//! whole body then immediately half-closing yields only keepalives / an
|
||||
//! `internal: No exec result` error, so the pacing is load-bearing.
|
||||
//!
|
||||
//! Response text arrives as `f1.f1.f1` string chunks (assistant answer) and
|
||||
//! `f1.f4.f1` chunks (reasoning). A trailing flag-`0x02` frame closes the turn.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bytes::Bytes;
|
||||
use flate2::read::GzDecoder;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{Instant, interval_at};
|
||||
use uuid::Uuid;
|
||||
|
||||
use jcode_message_types::StreamEvent;
|
||||
|
||||
const AGENT_HOST: &str = "agentn.global.api5.cursor.sh";
|
||||
const AGENT_PATH: &str = "/agent.v1.AgentService/Run";
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||
/// Client version advertised to Cursor's agent service. Must track a currently
|
||||
/// served `cursor-agent` CLI build; override at runtime with
|
||||
/// `JCODE_CURSOR_CLI_VERSION` if Cursor moves the floor.
|
||||
const CLI_CLIENT_VERSION_DEFAULT: &str = "cli-2026.07.08-0c04a8a";
|
||||
|
||||
fn cli_client_version() -> String {
|
||||
std::env::var("JCODE_CURSOR_CLI_VERSION")
|
||||
.ok()
|
||||
.map(|raw| raw.trim().to_string())
|
||||
.filter(|raw| !raw.is_empty())
|
||||
.unwrap_or_else(|| CLI_CLIENT_VERSION_DEFAULT.to_string())
|
||||
}
|
||||
|
||||
fn agent_host() -> String {
|
||||
std::env::var("JCODE_CURSOR_AGENT_HOST")
|
||||
.ok()
|
||||
.map(|raw| raw.trim().to_string())
|
||||
.filter(|raw| !raw.is_empty())
|
||||
.unwrap_or_else(|| AGENT_HOST.to_string())
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Protobuf + Connect framing helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
fn encode_varint(mut value: u64, out: &mut Vec<u8>) {
|
||||
while value >= 0x80 {
|
||||
out.push(((value as u8) & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
out.push(value as u8);
|
||||
}
|
||||
|
||||
/// Encode a length-delimited (wire type 2) protobuf field.
|
||||
fn field_ld(field: u64, data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len() + 4);
|
||||
encode_varint((field << 3) | 2, &mut out);
|
||||
encode_varint(data.len() as u64, &mut out);
|
||||
out.extend_from_slice(data);
|
||||
out
|
||||
}
|
||||
|
||||
/// Encode a varint (wire type 0) protobuf field.
|
||||
fn field_varint(field: u64, value: u64) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_varint(field << 3, &mut out);
|
||||
encode_varint(value, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn field_str(field: u64, s: &str) -> Vec<u8> {
|
||||
field_ld(field, s.as_bytes())
|
||||
}
|
||||
|
||||
/// Wrap a protobuf message payload in a Connect data frame (flag 0, uncompressed).
|
||||
fn connect_frame(payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(payload.len() + 5);
|
||||
out.push(0);
|
||||
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
/// `{f1: name, f3: {f1:'fast', f2:'true'|'false'}}` model descriptor.
|
||||
fn encode_model_meta(name: &str, fast: bool) -> Vec<u8> {
|
||||
let mut out = field_str(1, name);
|
||||
let mut kv = field_str(1, "fast");
|
||||
kv.extend(field_str(2, if fast { "true" } else { "false" }));
|
||||
out.extend(field_ld(3, &kv));
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the request frames for a single-shot prompt turn.
|
||||
///
|
||||
/// Returns the ordered list of Connect frames that constitute the streamed
|
||||
/// `RunInput`: `RunRequest`, environment context, then marker frames.
|
||||
fn build_run_frames(prompt: &str, model: &str, cwd: &str) -> Vec<Vec<u8>> {
|
||||
let conv = Uuid::new_v4().to_string();
|
||||
let msg = Uuid::new_v4().to_string();
|
||||
|
||||
// frame 0: field 1 = RunRequest
|
||||
// messages: f2 { f1 { f1 { f1:prompt, f2:msg_id, f3:'', f4:1 } } }
|
||||
let mut inner = field_str(1, prompt);
|
||||
inner.extend(field_str(2, &msg));
|
||||
inner.extend(field_str(3, ""));
|
||||
inner.extend(field_varint(4, 1));
|
||||
let messages = field_ld(2, &field_ld(1, &field_ld(1, &inner)));
|
||||
|
||||
let mut req = field_str(1, "");
|
||||
req.extend(messages);
|
||||
req.extend(field_str(4, ""));
|
||||
req.extend(field_str(5, &conv));
|
||||
req.extend(field_ld(9, &encode_model_meta(model, false)));
|
||||
req.extend(field_varint(12, 0));
|
||||
// minimal catalog: a "default" entry plus the target model
|
||||
req.extend(field_ld(14, &field_str(1, "default")));
|
||||
req.extend(field_ld(14, &encode_model_meta(model, false)));
|
||||
req.extend(field_str(16, &conv));
|
||||
let frame0 = connect_frame(&field_ld(1, &req));
|
||||
|
||||
// frame 1: field 2 = environment context (env block only, no tools/skills)
|
||||
let mut env = field_str(1, "linux");
|
||||
env.extend(field_str(2, cwd));
|
||||
env.extend(field_str(3, "bash"));
|
||||
env.extend(field_str(10, "UTC"));
|
||||
env.extend(field_str(11, cwd));
|
||||
env.extend(field_varint(14, 1));
|
||||
env.extend(field_varint(16, 1));
|
||||
env.extend(field_varint(19, 0));
|
||||
env.extend(field_varint(20, 0));
|
||||
env.extend(field_str(21, cwd));
|
||||
env.extend(field_varint(22, 0));
|
||||
let ctx_payload = field_ld(
|
||||
2,
|
||||
&field_ld(10, &field_ld(1, &field_ld(1, &field_ld(4, &env)))),
|
||||
);
|
||||
let frame1 = connect_frame(&ctx_payload);
|
||||
|
||||
// marker frames streamed after the context.
|
||||
let mut frames = vec![frame0, frame1];
|
||||
frames.push(connect_frame(&field_ld(5, &field_str(1, "")))); // f5{f1:''}
|
||||
frames.push(connect_frame(&field_ld(3, &field_str(3, "")))); // f3{f3:''}
|
||||
for n in 1..=8u64 {
|
||||
// f3{f1:N, f3:''}
|
||||
let mut m = field_varint(1, n);
|
||||
m.extend(field_str(3, ""));
|
||||
frames.push(connect_frame(&field_ld(3, &m)));
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
/// A single `f7:''` heartbeat frame.
|
||||
fn heartbeat_frame() -> Vec<u8> {
|
||||
connect_frame(&field_ld(7, &[]))
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Response parsing
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/// Incrementally decode Connect frames from a byte buffer, returning
|
||||
/// `(flag, payload, consumed)` for the next complete frame or `None`.
|
||||
fn next_frame(buf: &[u8]) -> Option<(u8, Vec<u8>, usize)> {
|
||||
if buf.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
let flag = buf[0];
|
||||
let len = u32::from_be_bytes([buf[1], buf[2], buf[3], buf[4]]) as usize;
|
||||
let end = 5 + len;
|
||||
if buf.len() < end {
|
||||
return None;
|
||||
}
|
||||
let mut payload = buf[5..end].to_vec();
|
||||
if flag & 0x01 != 0 {
|
||||
// gzip-compressed payload
|
||||
let mut decoded = Vec::new();
|
||||
if GzDecoder::new(&payload[..])
|
||||
.read_to_end(&mut decoded)
|
||||
.is_ok()
|
||||
{
|
||||
payload = decoded;
|
||||
}
|
||||
}
|
||||
Some((flag, payload, end))
|
||||
}
|
||||
|
||||
/// Minimal protobuf reader that extracts assistant text chunks from a response
|
||||
/// message. Text answer chunks live at `f1.f1.f1` (string); reasoning chunks at
|
||||
/// `f1.f4.f1` (string). We only surface the assistant answer to keep the stream
|
||||
/// clean, matching the old provider's text-only behavior.
|
||||
struct PbField<'a> {
|
||||
field: u64,
|
||||
wire: u8,
|
||||
data: &'a [u8],
|
||||
}
|
||||
|
||||
fn iter_fields(mut buf: &[u8]) -> impl Iterator<Item = PbField<'_>> {
|
||||
std::iter::from_fn(move || {
|
||||
if buf.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (tag, rest) = read_varint(buf)?;
|
||||
let field = tag >> 3;
|
||||
let wire = (tag & 7) as u8;
|
||||
buf = rest;
|
||||
match wire {
|
||||
0 => {
|
||||
let (_v, rest) = read_varint(buf)?;
|
||||
buf = rest;
|
||||
Some(PbField {
|
||||
field,
|
||||
wire,
|
||||
data: &[],
|
||||
})
|
||||
}
|
||||
2 => {
|
||||
let (len, rest) = read_varint(buf)?;
|
||||
let len = len as usize;
|
||||
if rest.len() < len {
|
||||
return None;
|
||||
}
|
||||
let data = &rest[..len];
|
||||
buf = &rest[len..];
|
||||
Some(PbField { field, wire, data })
|
||||
}
|
||||
5 => {
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
buf = &buf[4..];
|
||||
Some(PbField {
|
||||
field,
|
||||
wire,
|
||||
data: &[],
|
||||
})
|
||||
}
|
||||
1 => {
|
||||
if buf.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
buf = &buf[8..];
|
||||
Some(PbField {
|
||||
field,
|
||||
wire,
|
||||
data: &[],
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn read_varint(buf: &[u8]) -> Option<(u64, &[u8])> {
|
||||
let mut result = 0u64;
|
||||
let mut shift = 0u32;
|
||||
for (i, &byte) in buf.iter().enumerate() {
|
||||
result |= ((byte & 0x7f) as u64) << shift;
|
||||
if byte & 0x80 == 0 {
|
||||
return Some((result, &buf[i + 1..]));
|
||||
}
|
||||
shift += 7;
|
||||
if shift >= 64 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the assistant answer text delta from one response message payload.
|
||||
///
|
||||
/// The assistant-answer chunk shape is `f1 { f1 { f1: <str> } }`. We ignore
|
||||
/// reasoning (`f1.f4`) so the emitted stream matches plain chat text.
|
||||
fn extract_answer_text(payload: &[u8]) -> Option<String> {
|
||||
for f1 in iter_fields(payload) {
|
||||
if f1.field != 1 || f1.wire != 2 {
|
||||
continue;
|
||||
}
|
||||
for f1_1 in iter_fields(f1.data) {
|
||||
if f1_1.field != 1 || f1_1.wire != 2 {
|
||||
continue;
|
||||
}
|
||||
for leaf in iter_fields(f1_1.data) {
|
||||
if leaf.field == 1
|
||||
&& leaf.wire == 2
|
||||
&& let Ok(s) = std::str::from_utf8(leaf.data)
|
||||
&& !s.is_empty()
|
||||
{
|
||||
return Some(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TLS + HTTP/2 bidirectional client
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
fn tls_config() -> Arc<tokio_rustls::rustls::ClientConfig> {
|
||||
let mut roots = tokio_rustls::rustls::RootCertStore::empty();
|
||||
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
let mut config = tokio_rustls::rustls::ClientConfig::builder()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
config.alpn_protocols = vec![b"h2".to_vec()];
|
||||
Arc::new(config)
|
||||
}
|
||||
|
||||
/// Run one Cursor agent turn and forward assistant text as [`StreamEvent`]s.
|
||||
pub async fn run_agent_turn(
|
||||
access_token: &str,
|
||||
prompt: &str,
|
||||
model: &str,
|
||||
tx: mpsc::Sender<Result<StreamEvent>>,
|
||||
) -> Result<()> {
|
||||
use h2::client;
|
||||
use http::{Method, Request};
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
let host = agent_host();
|
||||
let cwd = std::env::current_dir()
|
||||
.ok()
|
||||
.and_then(|p| p.to_str().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
|
||||
let _ = tx
|
||||
.send(Ok(StreamEvent::ConnectionType {
|
||||
connection: "native http2 (agent)".to_string(),
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Establish TLS + HTTP/2.
|
||||
let tcp = tokio::net::TcpStream::connect((host.as_str(), 443))
|
||||
.await
|
||||
.with_context(|| format!("Failed to connect to {host}:443"))?;
|
||||
tcp.set_nodelay(true).ok();
|
||||
let connector = TlsConnector::from(tls_config());
|
||||
let server_name = tokio_rustls::rustls::pki_types::ServerName::try_from(host.clone())
|
||||
.context("Invalid Cursor agent host name")?;
|
||||
let tls = connector
|
||||
.connect(server_name, tcp)
|
||||
.await
|
||||
.context("TLS handshake with Cursor agent host failed")?;
|
||||
|
||||
let (h2, connection) = client::handshake(tls)
|
||||
.await
|
||||
.context("HTTP/2 handshake with Cursor agent host failed")?;
|
||||
// Drive the connection in the background.
|
||||
let conn_task = tokio::spawn(async move {
|
||||
let _ = connection.await;
|
||||
});
|
||||
let mut h2 = h2.ready().await.context("HTTP/2 connection not ready")?;
|
||||
|
||||
let request_id = Uuid::new_v4().to_string();
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(format!("https://{host}{AGENT_PATH}"))
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("connect-accept-encoding", "gzip,br")
|
||||
.header("connect-protocol-version", "1")
|
||||
.header("content-type", "application/connect+proto")
|
||||
.header("user-agent", "connect-es/1.6.1")
|
||||
.header("x-cursor-client-type", "cli")
|
||||
.header("x-cursor-client-version", cli_client_version())
|
||||
.header("x-ghost-mode", "true")
|
||||
.header("x-request-id", &request_id)
|
||||
.header("x-original-request-id", &request_id)
|
||||
.body(())
|
||||
.context("Failed to build Cursor agent request")?;
|
||||
|
||||
let (response, mut send_stream) = h2
|
||||
.send_request(request, false)
|
||||
.context("Failed to send Cursor agent request headers")?;
|
||||
|
||||
let session_id = Uuid::new_v5(&Uuid::NAMESPACE_DNS, access_token.as_bytes()).to_string();
|
||||
let _ = tx.send(Ok(StreamEvent::SessionId(session_id))).await;
|
||||
|
||||
// Sender task: stream the request frames paced like the real client, then
|
||||
// heartbeat until the response completes. The pacing is load-bearing: the
|
||||
// server treats the marker frames as end-of-input and returns
|
||||
// `internal: No exec result` if they arrive before it has begun streaming.
|
||||
let frames = build_run_frames(prompt, model, &cwd);
|
||||
let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let sender = tokio::spawn(async move {
|
||||
for (idx, frame) in frames.into_iter().enumerate() {
|
||||
if send_stream.send_data(Bytes::from(frame), false).is_err() {
|
||||
return;
|
||||
}
|
||||
// frame 0 (RunRequest) and frame 1 (context) need the most settle
|
||||
// time before the marker frames follow.
|
||||
let pace = match idx {
|
||||
0 => Duration::from_millis(1500),
|
||||
1 => Duration::from_millis(800),
|
||||
_ => Duration::from_millis(400),
|
||||
};
|
||||
tokio::time::sleep(pace).await;
|
||||
}
|
||||
let mut ticker = interval_at(Instant::now() + HEARTBEAT_INTERVAL, HEARTBEAT_INTERVAL);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut stop_rx => break,
|
||||
_ = ticker.tick() => {
|
||||
if send_stream.send_data(Bytes::from(heartbeat_frame()), false).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = send_stream.send_data(Bytes::new(), true);
|
||||
});
|
||||
|
||||
// Receiver: read response body frames and forward assistant text.
|
||||
let response = response
|
||||
.await
|
||||
.context("Cursor agent request failed before response headers")?;
|
||||
let status = response.status();
|
||||
let mut body = response.into_body();
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let mut error_message: Option<String> = None;
|
||||
let mut got_text = false;
|
||||
|
||||
// Idle timeouts guard against the server holding the stream open. Cursor
|
||||
// keeps the response side open after the assistant message when it expects
|
||||
// a tool exec-result (which this text-only transport never sends), so we
|
||||
// finish the turn once output goes quiet. The first-byte budget is longer
|
||||
// because generation can take a few seconds to start.
|
||||
let first_byte_timeout = Duration::from_secs(60);
|
||||
let idle_timeout = Duration::from_secs(4);
|
||||
|
||||
'read: loop {
|
||||
let budget = if got_text {
|
||||
idle_timeout
|
||||
} else {
|
||||
first_byte_timeout
|
||||
};
|
||||
let next = match tokio::time::timeout(budget, body.data()).await {
|
||||
Ok(Some(chunk)) => chunk,
|
||||
// Stream closed cleanly, or idle (server likely waiting for a tool
|
||||
// exec-result we never send): finish the turn either way.
|
||||
Ok(None) | Err(_) => break 'read,
|
||||
};
|
||||
let chunk = next.context("Cursor agent response stream error")?;
|
||||
let _ = body.flow_control().release_capacity(chunk.len());
|
||||
pending.extend_from_slice(&chunk);
|
||||
while let Some((flag, payload, consumed)) = next_frame(&pending) {
|
||||
pending.drain(..consumed);
|
||||
if flag & 0x02 != 0 {
|
||||
// end-of-stream trailer (JSON). Detect errors, then finish.
|
||||
if let Ok(text) = std::str::from_utf8(&payload)
|
||||
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(text)
|
||||
&& let Some(err) = json.get("error")
|
||||
{
|
||||
error_message = Some(err.to_string());
|
||||
}
|
||||
break 'read;
|
||||
}
|
||||
if let Some(text) = extract_answer_text(&payload) {
|
||||
got_text = true;
|
||||
if tx.send(Ok(StreamEvent::TextDelta(text))).await.is_err() {
|
||||
break 'read;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = stop_tx.send(());
|
||||
let _ = sender.await;
|
||||
conn_task.abort();
|
||||
|
||||
if let Some(err) = error_message {
|
||||
anyhow::bail!("Cursor agent stream error: {err}");
|
||||
}
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Cursor agent request failed with HTTP {status}");
|
||||
}
|
||||
let _ = got_text;
|
||||
|
||||
let _ = tx
|
||||
.send(Ok(StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
}))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frames_are_well_formed_connect_frames() {
|
||||
let frames = build_run_frames("hi", "composer-2.5", "/tmp");
|
||||
assert!(frames.len() >= 4);
|
||||
for frame in &frames {
|
||||
assert!(frame.len() >= 5);
|
||||
let len = u32::from_be_bytes([frame[1], frame[2], frame[3], frame[4]]) as usize;
|
||||
assert_eq!(
|
||||
len + 5,
|
||||
frame.len(),
|
||||
"frame length prefix must match payload"
|
||||
);
|
||||
assert_eq!(frame[0], 0, "request frames are uncompressed data frames");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame0_contains_prompt_and_model() {
|
||||
let frames = build_run_frames("PROMPT_MARKER", "composer-2.5", "/tmp");
|
||||
let frame0 = &frames[0];
|
||||
let hay = String::from_utf8_lossy(frame0);
|
||||
assert!(hay.contains("PROMPT_MARKER"));
|
||||
assert!(hay.contains("composer-2.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_answer_text_reads_nested_chunk() {
|
||||
// f1 { f1 { f1: "AUTH" } }
|
||||
let leaf = field_str(1, "AUTH");
|
||||
let mid = field_ld(1, &leaf);
|
||||
let top = field_ld(1, &mid);
|
||||
assert_eq!(extract_answer_text(&top).as_deref(), Some("AUTH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_answer_text_ignores_reasoning() {
|
||||
// f1 { f4 { f1: "thinking" } } should not be surfaced as answer text.
|
||||
let leaf = field_str(1, "thinking");
|
||||
let f4 = field_ld(4, &leaf);
|
||||
let top = field_ld(1, &f4);
|
||||
assert_eq!(extract_answer_text(&top), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_frame_parses_uncompressed() {
|
||||
let payload = field_str(1, "hello");
|
||||
let frame = connect_frame(&payload);
|
||||
let (flag, out, consumed) = next_frame(&frame).unwrap();
|
||||
assert_eq!(flag, 0);
|
||||
assert_eq!(consumed, frame.len());
|
||||
assert_eq!(out, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_is_stable() {
|
||||
assert_eq!(heartbeat_frame(), vec![0, 0, 0, 0, 2, 0x3a, 0x00]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn available_models_include_composer_models() {
|
||||
let provider = CursorCliProvider::new();
|
||||
let models = provider.available_models();
|
||||
assert!(models.contains(&"composer-2"));
|
||||
assert!(models.contains(&"composer-2.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_models_display_includes_custom_current_model() {
|
||||
let provider = CursorCliProvider::new();
|
||||
provider.set_model("future-cursor-model").unwrap();
|
||||
|
||||
let models = provider.available_models_display();
|
||||
assert!(models.contains(&"future-cursor-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_models_display_prefers_fetched_cursor_models() {
|
||||
let provider = CursorCliProvider::new();
|
||||
*provider.fetched_models.write().unwrap() = vec![
|
||||
"claude-4-sonnet-thinking".to_string(),
|
||||
"gpt-5.2".to_string(),
|
||||
];
|
||||
|
||||
let models = provider.available_models_display();
|
||||
assert_eq!(
|
||||
models.first().map(|model| model.as_str()),
|
||||
Some("claude-4-sonnet-thinking")
|
||||
);
|
||||
assert!(models.iter().any(|model| model == "gpt-5.2"));
|
||||
assert!(models.iter().any(|model| model == "composer-2.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_cursor_models_deduplicates_dynamic_entries() {
|
||||
let models = merge_cursor_models(
|
||||
&[
|
||||
"composer-2".to_string(),
|
||||
"claude-4-sonnet-thinking".to_string(),
|
||||
"claude-4-sonnet-thinking".to_string(),
|
||||
],
|
||||
"claude-4-sonnet-thinking",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
models
|
||||
.iter()
|
||||
.filter(|model| model.as_str() == "claude-4-sonnet-thinking")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(models.iter().any(|model| model == "composer-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_models_display_seeds_from_persisted_catalog() {
|
||||
let _guard = jcode_base::storage::lock_test_env();
|
||||
let temp = tempfile::TempDir::new().expect("tempdir");
|
||||
let prev_home = std::env::var_os("JCODE_HOME");
|
||||
jcode_base::env::set_var("JCODE_HOME", temp.path());
|
||||
|
||||
let path = CursorCliProvider::persisted_catalog_path().expect("catalog path");
|
||||
jcode_base::storage::write_json(
|
||||
&path,
|
||||
&PersistedCatalog {
|
||||
models: vec!["cursor-disk-model".to_string()],
|
||||
fetched_at_rfc3339: chrono::Utc::now().to_rfc3339(),
|
||||
},
|
||||
)
|
||||
.expect("write persisted catalog");
|
||||
|
||||
let provider = CursorCliProvider::new();
|
||||
assert!(
|
||||
provider
|
||||
.available_models_display()
|
||||
.contains(&"cursor-disk-model".to_string())
|
||||
);
|
||||
|
||||
if let Some(prev_home) = prev_home {
|
||||
jcode_base::env::set_var("JCODE_HOME", prev_home);
|
||||
} else {
|
||||
jcode_base::env::remove_var("JCODE_HOME");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_model_accepts_composer_models() {
|
||||
let provider = CursorCliProvider::new();
|
||||
|
||||
provider.set_model("composer-2").unwrap();
|
||||
assert_eq!(provider.model(), "composer-2");
|
||||
|
||||
provider.set_model("composer-2.5").unwrap();
|
||||
assert_eq!(provider.model(), "composer-2.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_cursor_api_key_reads_env() {
|
||||
let previous = std::env::var_os("CURSOR_API_KEY");
|
||||
jcode_base::env::set_var("CURSOR_API_KEY", "cursor-env-test");
|
||||
|
||||
assert_eq!(runtime_cursor_api_key().as_deref(), Some("cursor-env-test"));
|
||||
|
||||
if let Some(previous) = previous {
|
||||
jcode_base::env::set_var("CURSOR_API_KEY", previous);
|
||||
} else {
|
||||
jcode_base::env::remove_var("CURSOR_API_KEY");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
//! Cursor provider runtime (direct ChatService HTTP/2 streaming), moved out
|
||||
//! of `jcode-base` so provider edits compile only this crate plus a binary
|
||||
//! relink instead of rebuilding the base -> app-core -> tui spine. The
|
||||
//! binary's composition root registers [`CursorCliProvider`] with
|
||||
//! `jcode_base::provider::external` at startup.
|
||||
//!
|
||||
//! The pure model-catalog data (`AVAILABLE_MODELS`, `is_known_model`) stays in
|
||||
//! `jcode_base::provider::cursor` because base's model-routing logic needs it
|
||||
//! without a runtime.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use jcode_base::auth::cursor as cursor_auth;
|
||||
use jcode_base::provider::cursor::{AVAILABLE_MODELS, DEFAULT_MODEL};
|
||||
use jcode_message_types::{ContentBlock, Message, Role, StreamEvent, ToolDefinition};
|
||||
use jcode_provider_core::{EventStream, Provider};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
mod agent_transport;
|
||||
|
||||
const MODELS_API_URL: &str = "https://api.cursor.com/v0/models";
|
||||
const MAX_PROMPT_CHARS: usize = 120_000;
|
||||
|
||||
fn build_cli_prompt(system: &str, messages: &[Message]) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
if !system.trim().is_empty() {
|
||||
out.push_str("System:\n");
|
||||
out.push_str(system.trim());
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
out.push_str("Conversation:\n");
|
||||
|
||||
for message in messages {
|
||||
let role = match message.role {
|
||||
Role::User => "User",
|
||||
Role::Assistant => "Assistant",
|
||||
};
|
||||
out.push_str(role);
|
||||
out.push_str(":\n");
|
||||
|
||||
for block in &message.content {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => {
|
||||
out.push_str(text);
|
||||
out.push('\n');
|
||||
}
|
||||
ContentBlock::Reasoning { .. }
|
||||
| ContentBlock::ReasoningTrace { .. }
|
||||
| ContentBlock::AnthropicThinking { .. }
|
||||
| ContentBlock::OpenAIReasoning { .. } => {}
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
out.push_str("[tool_use ");
|
||||
out.push_str(name);
|
||||
out.push_str(" input=");
|
||||
out.push_str(&input.to_string());
|
||||
out.push_str("]\n");
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
out.push_str("[tool_result ");
|
||||
out.push_str(tool_use_id);
|
||||
out.push_str(" is_error=");
|
||||
out.push_str(if is_error.unwrap_or(false) {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
});
|
||||
out.push_str("]\n");
|
||||
out.push_str(content);
|
||||
out.push('\n');
|
||||
}
|
||||
ContentBlock::Image { .. } => {
|
||||
out.push_str("[image]\n");
|
||||
}
|
||||
ContentBlock::OpenAICompaction { .. } => {
|
||||
out.push_str("[openai native compaction]\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
out.push_str("Assistant:\n");
|
||||
|
||||
if out.chars().count() <= MAX_PROMPT_CHARS {
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut kept = out.chars().rev().take(MAX_PROMPT_CHARS).collect::<Vec<_>>();
|
||||
kept.reverse();
|
||||
let tail: String = kept.into_iter().collect();
|
||||
format!(
|
||||
"[Earlier conversation truncated to fit prompt limits]\n\n{}",
|
||||
tail
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CursorModelsResponse {
|
||||
#[serde(default)]
|
||||
models: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct PersistedCatalog {
|
||||
models: Vec<String>,
|
||||
fetched_at_rfc3339: String,
|
||||
}
|
||||
|
||||
fn merge_cursor_models(dynamic: &[String], current: &str) -> Vec<String> {
|
||||
let mut merged = Vec::new();
|
||||
|
||||
for model in dynamic {
|
||||
let trimmed = model.trim();
|
||||
if !trimmed.is_empty() && !merged.iter().any(|known| known == trimmed) {
|
||||
merged.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
for model in AVAILABLE_MODELS {
|
||||
let trimmed = model.trim();
|
||||
if !trimmed.is_empty() && !merged.iter().any(|known| known == trimmed) {
|
||||
merged.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let current = current.trim();
|
||||
if !current.is_empty() && !merged.iter().any(|known| known == current) {
|
||||
merged.push(current.to_string());
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
async fn fetch_available_models(client: &reqwest::Client, api_key: &str) -> Result<Vec<String>> {
|
||||
let response = client
|
||||
.get(MODELS_API_URL)
|
||||
.basic_auth(api_key, Some(""))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch Cursor model catalog")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = jcode_base::util::http_error_body(response, "HTTP error").await;
|
||||
anyhow::bail!(
|
||||
"Cursor model catalog request failed ({}): {}",
|
||||
status,
|
||||
body.trim()
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: CursorModelsResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to decode Cursor model catalog response")?;
|
||||
Ok(parsed
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|model| model.trim().to_string())
|
||||
.filter(|model| !model.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn runtime_cursor_api_key() -> Option<String> {
|
||||
jcode_base::auth::cursor::load_api_key().ok()
|
||||
}
|
||||
|
||||
pub struct CursorCliProvider {
|
||||
client: reqwest::Client,
|
||||
model: Arc<RwLock<String>>,
|
||||
fetched_models: Arc<RwLock<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl CursorCliProvider {
|
||||
fn persisted_catalog_path() -> Result<std::path::PathBuf> {
|
||||
Ok(jcode_base::storage::app_config_dir()?.join("cursor_models_cache.json"))
|
||||
}
|
||||
|
||||
fn load_persisted_catalog() -> Option<PersistedCatalog> {
|
||||
let path = Self::persisted_catalog_path().ok()?;
|
||||
jcode_base::storage::read_json(&path)
|
||||
.ok()
|
||||
.filter(|catalog: &PersistedCatalog| !catalog.models.is_empty())
|
||||
}
|
||||
|
||||
fn persist_catalog(models: &[String]) {
|
||||
if models.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(path) = Self::persisted_catalog_path() else {
|
||||
return;
|
||||
};
|
||||
let payload = PersistedCatalog {
|
||||
models: models.to_vec(),
|
||||
fetched_at_rfc3339: Utc::now().to_rfc3339(),
|
||||
};
|
||||
if let Err(error) = jcode_base::storage::write_json(&path, &payload) {
|
||||
jcode_base::logging::warn(&format!(
|
||||
"Failed to persist Cursor model catalog {}: {}",
|
||||
path.display(),
|
||||
error
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_cached_catalog(&self) {
|
||||
if let Some(catalog) = Self::load_persisted_catalog()
|
||||
&& let Ok(mut models) = self.fetched_models.write()
|
||||
{
|
||||
*models = catalog.models;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
let model = std::env::var("JCODE_CURSOR_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
|
||||
let provider = Self {
|
||||
client: jcode_provider_core::shared_http_client(),
|
||||
model: Arc::new(RwLock::new(model)),
|
||||
fetched_models: Arc::new(RwLock::new(Vec::new())),
|
||||
};
|
||||
provider.seed_cached_catalog();
|
||||
provider
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CursorCliProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for CursorCliProvider {
|
||||
async fn complete(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
_tools: &[ToolDefinition],
|
||||
system: &str,
|
||||
_resume_session_id: Option<&str>,
|
||||
) -> Result<EventStream> {
|
||||
let prompt = build_cli_prompt(system, messages);
|
||||
let model = self
|
||||
.model
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone();
|
||||
let prompt_items = vec![Value::String(prompt.clone())];
|
||||
let system_value = (!system.trim().is_empty()).then(|| Value::String(system.to_string()));
|
||||
let payload = json!({
|
||||
"model": &model,
|
||||
"system": system_value.as_ref(),
|
||||
"prompt": &prompt,
|
||||
});
|
||||
jcode_provider_core::fingerprint::log_provider_canonical_input(
|
||||
"cursor",
|
||||
&model,
|
||||
"cursor_cli_prompt",
|
||||
&payload,
|
||||
&prompt_items,
|
||||
system_value.as_ref(),
|
||||
None,
|
||||
Some(0),
|
||||
&[
|
||||
("logical_message_count", messages.len().to_string()),
|
||||
("ignored_tool_count", _tools.len().to_string()),
|
||||
],
|
||||
);
|
||||
let client = self.client.clone();
|
||||
let (tx, rx) = mpsc::channel::<Result<jcode_message_types::StreamEvent>>(100);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = run_native_text_command(client, tx.clone(), &prompt, &model).await;
|
||||
|
||||
if let Err(err) = result {
|
||||
let _ = tx.send(Err(err)).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"cursor"
|
||||
}
|
||||
|
||||
fn model(&self) -> String {
|
||||
self.model
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn set_model(&self, model: &str) -> Result<()> {
|
||||
let trimmed = model.trim();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("Cursor model cannot be empty");
|
||||
}
|
||||
*self
|
||||
.model
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = trimmed.to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn available_models(&self) -> Vec<&'static str> {
|
||||
AVAILABLE_MODELS.to_vec()
|
||||
}
|
||||
|
||||
fn available_models_for_switching(&self) -> Vec<String> {
|
||||
self.available_models_display()
|
||||
}
|
||||
|
||||
fn available_models_display(&self) -> Vec<String> {
|
||||
let dynamic = self
|
||||
.fetched_models
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone();
|
||||
merge_cursor_models(&dynamic, &self.model())
|
||||
}
|
||||
|
||||
fn model_routes(&self) -> Vec<jcode_provider_core::ModelRoute> {
|
||||
self.available_models_display()
|
||||
.into_iter()
|
||||
.map(|model| jcode_provider_core::ModelRoute {
|
||||
model,
|
||||
provider: "Cursor".to_string(),
|
||||
api_method: "cursor".to_string(),
|
||||
available: true,
|
||||
detail: String::new(),
|
||||
cheapness: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn prefetch_models(&self) -> Result<()> {
|
||||
let Some(api_key) = runtime_cursor_api_key() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match fetch_available_models(&self.client, &api_key).await {
|
||||
Ok(models) => {
|
||||
if !models.is_empty() {
|
||||
jcode_base::logging::info(&format!(
|
||||
"Discovered Cursor models: {}",
|
||||
models.join(", ")
|
||||
));
|
||||
Self::persist_catalog(&models);
|
||||
*self
|
||||
.fetched_models
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = models;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
jcode_base::logging::warn(&format!(
|
||||
"Cursor model catalog refresh failed; keeping fallback list: {}",
|
||||
err
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handles_tools_internally(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn supports_compaction(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn fork(&self) -> Arc<dyn Provider> {
|
||||
Arc::new(Self {
|
||||
client: self.client.clone(),
|
||||
model: Arc::new(RwLock::new(self.model())),
|
||||
fetched_models: self.fetched_models.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_native_text_command(
|
||||
client: reqwest::Client,
|
||||
tx: mpsc::Sender<Result<StreamEvent>>,
|
||||
prompt: &str,
|
||||
model: &str,
|
||||
) -> Result<()> {
|
||||
let tokens = cursor_auth::resolve_direct_tokens(&client).await?;
|
||||
|
||||
// The current Cursor agent transport (`agent.v1.AgentService/Run`) is a
|
||||
// paced bidirectional Connect/HTTP2 stream. The old
|
||||
// `ChatService/StreamUnifiedChatWithTools` endpoint was decommissioned for
|
||||
// API-key / CLI tokens and now returns "Update Required"/payment errors.
|
||||
let first_result =
|
||||
crate::agent_transport::run_agent_turn(&tokens.access_token, prompt, model, tx.clone())
|
||||
.await;
|
||||
|
||||
match first_result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if cursor_auth::error_indicates_not_logged_in(&err) => {
|
||||
let refreshed = cursor_auth::refresh_resolved_tokens(&client, &tokens)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Cursor token was rejected and refresh also failed after: {err:#}")
|
||||
})?;
|
||||
crate::agent_transport::run_agent_turn(&refreshed.access_token, prompt, model, tx).await
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cursor_tests.rs"]
|
||||
mod cursor_tests;
|
||||
Reference in New Issue
Block a user