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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
@@ -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;