Merge branch 'main' of github.com:leookun/cursor-byok

This commit is contained in:
leokun
2026-09-07 09:47:48 +08:00
15 changed files with 743 additions and 50 deletions
Generated
+1 -1
View File
@@ -1172,7 +1172,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "cursor-byok-desktop"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"axum",
"cursor-server",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.6",
"version": "0.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.6",
"version": "0.1.7",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.6",
"version": "0.1.7",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.6"
version = "0.1.7"
edition = "2021"
publish = false
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.6",
"version": "0.1.7",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
+6 -4
View File
@@ -6,6 +6,7 @@ import cursorIconUrl from "../shared/assets/icons/cursor.svg";
import { api } from "../shared/api";
import { AdMenu } from "./ads/AdMenu";
import { FloatingAd } from "./ads/FloatingAd";
import { loadCachedAds, saveCachedAds } from "./ads/cache";
import { AdActionType, type AdAction, type AdSlot } from "./ads/types";
import { PageLayout } from "./layout/PageLayout";
import { Card } from "../shared/ui/Card";
@@ -50,7 +51,7 @@ export function AppLayout() {
const location = useLocation();
const [leftActionTarget, setLeftActionTarget] = useState<HTMLDivElement | null>(null);
const [rightActionTarget, setRightActionTarget] = useState<HTMLDivElement | null>(null);
const [ads, setAds] = useState<AdSlot[]>([]);
const [ads, setAds] = useState<AdSlot[]>(() => loadCachedAds());
const [activeAd, setActiveAd] = useState<AdSlot | null>(null);
const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null);
const [dismissReason, setDismissReason] = useState("");
@@ -98,6 +99,8 @@ export function AppLayout() {
let disposed = false;
let pending = false;
let lastRequestedAt = 0;
setAds(loadCachedAds());
setActiveAd(null);
const refreshAds = () => {
const now = Date.now();
if (pending || now - lastRequestedAt < 10_000) return;
@@ -105,6 +108,7 @@ export function AppLayout() {
lastRequestedAt = now;
void api.ads(dismissedAdIdsRef.current, locale)
.then((runtime) => {
saveCachedAds(runtime.slots);
if (disposed) return;
setAds(runtime.slots);
setActiveAd((current) => current
@@ -112,9 +116,7 @@ export function AppLayout() {
: null);
})
.catch(() => {
if (disposed) return;
setAds([]);
setActiveAd(null);
// Keep the current ads and local cache available when refreshing fails.
})
.finally(() => { pending = false; });
};
+21
View File
@@ -0,0 +1,21 @@
import type { AdSlot } from "./types";
const adCacheStorageKey = "cursor-byok:ad-runtime";
export function loadCachedAds(): AdSlot[] {
try {
const value: unknown = JSON.parse(localStorage.getItem(adCacheStorageKey) ?? "null");
if (!value || typeof value !== "object" || !Array.isArray((value as { slots?: unknown }).slots)) return [];
return (value as { slots: AdSlot[] }).slots;
} catch {
return [];
}
}
export function saveCachedAds(slots: AdSlot[]): void {
try {
localStorage.setItem(adCacheStorageKey, JSON.stringify({ slots }));
} catch {
// The current session still uses the fetched ads when storage is unavailable.
}
}
@@ -517,11 +517,9 @@ fn gate_mcp_resources(tool: &mut pb::ListMcpResourcesToolCall) {
.push(pb::list_mcp_resources_exec_result::McpResource {
uri: "truncated:list-mcp-resources".into(),
name: Some("truncated".into()),
description: Some(truncation_notice(
"ListMcpResources",
MCP_TEXT_LIMIT,
success.resources.len(),
original,
description: Some(format!(
"[truncated: ListMcpResources result exceeded {MCP_RESOURCE_LIMIT} resources; showing {} of {original} resources]",
success.resources.len()
)),
..Default::default()
});
@@ -861,4 +859,39 @@ mod tests {
let mut content = String::new();
tool_completion("Grep", &mut tool, &mut content);
}
#[test]
fn list_mcp_resources_reports_the_cap_it_actually_applied() {
let resources = (0..MCP_RESOURCE_LIMIT + 50)
.map(|index| pb::list_mcp_resources_exec_result::McpResource {
uri: format!("mcp://resource/{index}"),
..Default::default()
})
.collect();
let mut tool = pb::ListMcpResourcesToolCall {
args: None,
result: Some(pb::ListMcpResourcesExecResult {
result: Some(pb::list_mcp_resources_exec_result::Result::Success(
pb::ListMcpResourcesSuccess { resources },
)),
}),
};
gate_mcp_resources(&mut tool);
let pb::list_mcp_resources_exec_result::Result::Success(success) =
tool.result.unwrap().result.unwrap()
else {
panic!("expected a successful result");
};
let notice = success.resources.last().unwrap();
assert_eq!(notice.uri, "truncated:list-mcp-resources");
assert_eq!(
notice.description.as_deref(),
Some(
"[truncated: ListMcpResources result exceeded 200 resources; \
showing 200 of 250 resources]"
)
);
}
}
+9 -12
View File
@@ -153,19 +153,7 @@ impl CursorHarness {
}
pub async fn set_enabled(&self, enabled: bool) -> Result<CursorHarnessStatus> {
let settings_applied = {
let proxy = self.inner.proxy.lock().await;
proxy
.url()
.as_deref()
.map(settings::settings_match)
.transpose()?
.unwrap_or(false)
};
if enabled {
if !settings_applied {
process::terminate_cursor().await?;
}
self.inner.store.set_cursor_takeover_enabled(true).await?;
self.enable().await?;
} else {
@@ -193,6 +181,15 @@ impl CursorHarness {
.read()
.ok_or_else(|| Error::Config("desktop management server is not ready".into()))?;
let mut proxy = self.inner.proxy.lock().await;
let settings_applied = proxy
.url()
.as_deref()
.map(settings::settings_match)
.transpose()?
.unwrap_or(false);
if !settings_applied {
process::terminate_cursor().await?;
}
if proxy.running() {
if let Some(url) = proxy.url() {
apply_cursor_configuration(&url).await?;
+258 -17
View File
@@ -5,22 +5,118 @@ use std::collections::HashSet;
use crate::{
model::{
estimate_context_tokens, estimate_projected_messages_tokens, CanonicalMessage, PreparedRun,
ProjectedMessage,
ProjectedContent, ProjectedMessage, Role,
},
store::ContextUsageAnchor,
};
const FALLBACK_CHARS: usize = 12_000;
pub(super) const RESERVE_TOKENS: u64 = 10_000;
/// Fraction of the context window kept free, as a divisor: 10 = 10%.
///
/// The estimate runs behind the provider: the anchor is what the provider
/// charged for the *previous* call, and the next request re-sends request
/// context and carries provider-side overhead the message-tail estimate does
/// not model. A real conversation measured 948K estimated against 1,017,628
/// actual, a 7% shortfall that landed it over a 1M window while the check said
/// there was room. A proportional reserve absorbs that drift and scales with
/// the model: a 200K window keeps 20K free and a 1M window keeps 100K.
const CONTEXT_RESERVE_DIVISOR: u64 = 10;
pub(super) const OUTPUT_TOKENS: u64 = 4_096;
pub(super) const INSTRUCTIONS: &str = "Summarize the conversation for the next model turn. Preserve goals, constraints, decisions, files, commands, errors, results, and unfinished work. Do not call tools. Return only the concise durable summary.";
/// Usable prompt budget: the window minus the proportional reserve.
pub(super) fn context_budget(context_window: u64) -> u64 {
context_window.saturating_sub(context_window / CONTEXT_RESERVE_DIVISOR)
}
pub(super) fn input_budget(prepared: &PreparedRun) -> Option<u64> {
prepared
.model
.context_window_tokens
.map(|window| window.saturating_sub(RESERVE_TOKENS))
prepared.model.context_window_tokens.map(context_budget)
}
/// Whether a provider failure means the prompt did not fit.
///
/// Providers report this as a plain 400 with prose, so there is nothing
/// structured to match on. Anthropic says "prompt is too long"; OpenAI-style
/// gateways use `context_length_exceeded` or "maximum context length".
pub(super) fn is_context_overflow(message: &str) -> bool {
let lowered = message.to_ascii_lowercase();
lowered.contains("prompt is too long")
|| lowered.contains("context window exceeded")
|| lowered.contains("model_context_window_exceeded")
|| lowered.contains("context_length_exceeded")
|| (lowered.contains("maximum context length") && lowered.contains("token"))
}
/// Builds the history for a compaction call.
///
/// Two properties matter, and replaying the raw history guarantees neither:
///
/// 1. The history must end with a user message. Otherwise providers read the
/// request as an assistant prefill and refuse it outright: Anthropic answers
/// "This model does not support assistant message prefill. The conversation
/// must end with a user message."
/// 2. The history must fit the context window. Compaction runs precisely
/// because the conversation is too large, so replaying all of it asks the
/// summarizer to accept a prompt that is already over the limit and the
/// call fails with "prompt is too long".
///
/// Either failure falls back to the truncated summary, which is usually still
/// too large, so the conversation stays over its window and cannot recover.
///
/// Trimming keeps the most recent turns and only ever cuts at a user-message
/// boundary, so an assistant tool call is never separated from its results.
pub(super) fn compaction_history(
history: Vec<ProjectedMessage>,
context_window: Option<u64>,
) -> Vec<ProjectedMessage> {
super::history::user_terminated(
trim_to_context(history, context_window),
"compaction:instruction",
INSTRUCTIONS,
)
}
fn trim_to_context(
mut history: Vec<ProjectedMessage>,
context_window: Option<u64>,
) -> Vec<ProjectedMessage> {
let Some(budget) = context_window
.filter(|window| *window > 0)
.map(context_budget)
.map(|budget| budget.saturating_sub(OUTPUT_TOKENS))
.filter(|budget| *budget > 0)
else {
return history;
};
if estimate_projected_messages_tokens(&history) <= budget {
return history;
}
// Walk back from the newest turn, keeping whole user-delimited turns.
let mut kept = 0;
let mut newest_turn = None;
for (index, message) in history.iter().enumerate().rev() {
if !is_turn_boundary(message) {
continue;
}
newest_turn.get_or_insert(index);
if estimate_projected_messages_tokens(&history[index..]) > budget {
break;
}
kept = history.len() - index;
}
if kept == 0 {
// Not even the newest turn fits. Keep it anyway rather than sending an
// empty history: an empty summarize call returns a summary of nothing
// that would then replace the whole conversation.
let start = newest_turn.unwrap_or(0);
return history.split_off(start);
}
history.split_off(history.len() - kept)
}
fn is_turn_boundary(message: &ProjectedMessage) -> bool {
message.role == Role::User && matches!(message.content, ProjectedContent::Parts(_))
}
pub(super) fn estimated_tokens(
@@ -115,8 +211,8 @@ pub(super) fn fallback_summary(messages: &[CanonicalMessage]) -> String {
mod tests {
use super::*;
use crate::model::{
project_messages, CheckpointId, ConversationId, ModelSpec, Origin, PromptSpec, Role,
RunAction, RunId, RunKind,
project_messages, CheckpointId, ContentPart, ConversationId, ModelSpec, Origin, PromptSpec,
Role, RunAction, RunId, RunKind,
};
fn prepared(context_window_tokens: u64) -> PreparedRun {
@@ -139,7 +235,7 @@ mod tests {
}
#[test]
fn automatic_compaction_uses_fixed_reserve_for_every_action() {
fn automatic_compaction_uses_proportional_reserve_for_every_action() {
let messages = vec![CanonicalMessage::text(
"user",
Role::User,
@@ -148,10 +244,14 @@ mod tests {
)];
let projected = project_messages(&messages).unwrap();
let estimated = estimate_context_tokens(&prepared(1).prompt, &projected);
let mut prepared = prepared(estimated + RESERVE_TOKENS);
// Smallest multiple-of-ten window whose 90% budget covers the estimate.
let window = estimated.div_ceil(9) * 10;
let mut prepared = prepared(window);
assert!(context_budget(window) >= estimated);
assert!(context_budget(window - 10) < estimated);
assert!(!should_compact(&prepared, &projected, None));
prepared.model.context_window_tokens = Some(estimated + RESERVE_TOKENS - 1);
prepared.model.context_window_tokens = Some(window - 10);
assert!(should_compact(&prepared, &projected, None));
prepared.action = RunAction::Resume {
@@ -160,6 +260,146 @@ mod tests {
assert!(should_compact(&prepared, &projected, None));
}
#[test]
fn the_reserve_leaves_room_for_the_estimate_to_run_behind() {
// Reproduces a conversation that wedged itself against a 1M window.
// The anchor said 947,797 tokens, so a fixed 10K reserve found room
// and let the request through. Anthropic counted 1,017,628 and
// refused it, and every retry repeated the same arithmetic.
let messages = vec![CanonicalMessage::text(
"user",
Role::User,
Origin::Runtime,
"hello",
)];
let projected = project_messages(&messages).unwrap();
let anchor = ContextUsageAnchor {
context_input_tokens: 947_797,
message_count: 1,
};
assert!(should_compact(
&prepared(1_000_000),
&projected,
Some(anchor)
));
}
#[test]
fn the_reserve_scales_with_the_window() {
assert_eq!(context_budget(200_000), 180_000);
assert_eq!(context_budget(1_000_000), 900_000);
assert_eq!(context_budget(0), 0);
assert_eq!(context_budget(1), 1);
}
#[test]
fn provider_refusals_that_mean_the_prompt_did_not_fit_are_recognized() {
assert!(is_context_overflow(
"provider error: Anthropic 400 Bad Request: {\"type\":\"error\",\"error\":\
{\"type\":\"invalid_request_error\",\"message\":\"prompt is too long: \
1017628 tokens > 1000000 maximum\"}}"
));
assert!(is_context_overflow("model_context_window_exceeded"));
assert!(is_context_overflow("context_length_exceeded"));
assert!(is_context_overflow(
"This model's maximum context length is 128000 tokens"
));
// Unrelated failures must not trigger a compaction, which would
// destroy history to fix something compaction cannot fix.
assert!(!is_context_overflow("401 Unauthorized: invalid api key"));
assert!(!is_context_overflow("429 Too Many Requests"));
assert!(!is_context_overflow(
"This model does not support assistant message prefill"
));
}
fn user(id: &str, text: &str) -> ProjectedMessage {
ProjectedMessage {
message_id: id.into(),
role: Role::User,
content: ProjectedContent::Parts(vec![ContentPart::Text { text: text.into() }]),
}
}
fn assistant(id: &str, text: &str) -> ProjectedMessage {
ProjectedMessage {
message_id: id.into(),
role: Role::Assistant,
content: ProjectedContent::Assistant {
text: text.into(),
thinking: String::new(),
replay_state: None,
calls: Vec::new(),
},
}
}
#[test]
fn compaction_history_always_ends_with_a_user_message() {
// Providers reject an assistant-terminated history as a prefill, which
// made every automatic compaction fall back to the truncated summary.
let history = vec![user("u1", "question"), assistant("a1", "answer")];
let prepared = compaction_history(history, Some(200_000));
assert_eq!(prepared.last().unwrap().role, Role::User);
assert_eq!(
prepared.last().unwrap().message_id,
"compaction:instruction"
);
// An already user-terminated history is left alone.
let history = vec![assistant("a1", "answer"), user("u2", "next")];
let prepared = compaction_history(history.clone(), Some(200_000));
assert_eq!(prepared, history);
}
#[test]
fn compaction_history_is_trimmed_to_fit_the_context_window() {
// Compaction runs because the conversation is too large, so the
// summarize call must not replay a prompt that is over the window.
let big = "x".repeat(400_000);
let history = vec![
user("u1", &big),
assistant("a1", &big),
user("u2", &big),
assistant("a2", "recent answer"),
];
let window = 200_000;
let prepared = compaction_history(history, Some(window));
let budget = context_budget(window) - OUTPUT_TOKENS;
assert!(estimate_projected_messages_tokens(&prepared) <= budget);
assert_eq!(prepared.last().unwrap().role, Role::User);
// The newest turn survives the trim and is never split.
assert!(prepared.iter().any(|message| message.message_id == "u2"));
assert!(prepared.iter().any(|message| message.message_id == "a2"));
assert!(!prepared.iter().any(|message| message.message_id == "u1"));
}
#[test]
fn compaction_history_keeps_the_newest_turn_even_when_it_is_over_budget() {
// A history whose newest turn alone exceeds the budget is still sent
// rather than trimmed to nothing: a summary of nothing would replace
// the whole conversation.
let history = vec![
user("u1", "old"),
assistant("a1", "old answer"),
user("u2", &"x".repeat(400_000)),
assistant("a2", "answer"),
];
let prepared = compaction_history(history, Some(50_000));
assert_eq!(prepared[0].message_id, "u2");
assert_eq!(prepared.last().unwrap().role, Role::User);
}
#[test]
fn compaction_history_without_a_context_window_is_untouched_apart_from_termination() {
let history = vec![user("u1", "question"), assistant("a1", "answer")];
let prepared = compaction_history(history.clone(), None);
assert_eq!(prepared[..2], history[..]);
assert_eq!(prepared.last().unwrap().role, Role::User);
}
#[test]
fn provider_usage_anchor_only_estimates_messages_added_after_last_request() {
let messages = vec![
@@ -253,15 +493,16 @@ mod tests {
)];
let projected = project_messages(&messages).unwrap();
let estimated = estimate_context_tokens(&prepared(1).prompt, &projected);
let window = estimated.div_ceil(9) * 10;
assert!(context_budget(window) >= estimated);
assert!(context_budget(window - 10) < estimated);
assert_eq!(
validate_compacted(&prepared(estimated + RESERVE_TOKENS), &projected),
validate_compacted(&prepared(window), &projected),
Ok(estimated)
);
assert!(
validate_compacted(&prepared(estimated + RESERVE_TOKENS - 1), &projected)
.unwrap_err()
.contains("context overflow after compaction")
);
assert!(validate_compacted(&prepared(window - 10), &projected)
.unwrap_err()
.contains("context overflow after compaction"));
}
}
+81 -4
View File
@@ -25,6 +25,12 @@ pub struct RunEngine {
provider: Arc<dyn Provider>,
}
/// Provider-visible tail appended when the committed history ends with the
/// assistant, which happens when Cursor resumes a turn that had already
/// finished (for example after a cancelled follow-up).
const CONTINUE_MESSAGE_ID: &str = "runtime:continue";
const CONTINUE_INSTRUCTION: &str = "Continue from where you left off.";
impl RunEngine {
pub fn new(store: Store, provider: Arc<dyn Provider>) -> Self {
Self { store, provider }
@@ -171,6 +177,10 @@ impl RunEngine {
};
}
// A provider refusal for an over-limit prompt triggers one compaction
// per run. A second refusal after compacting means the current input
// itself does not fit, and compacting again would only destroy history.
let mut overflow_compacted = false;
'model: loop {
if cancellation.is_cancelled() {
return (RunOutcome::Cancelled, usage);
@@ -226,6 +236,18 @@ impl RunEngine {
if let Err(error) = hydrate_tool_images(&self.store, &mut history).await {
return (RunOutcome::Failed(error.into()), usage);
}
// The usage anchor counts persisted messages only; a transient
// tail is provider-visible but never committed.
let anchored_messages = history.len();
let history = if prepared.action == RunAction::Compact {
super::history::user_terminated(
history,
"compaction:instruction",
super::compaction::INSTRUCTIONS,
)
} else {
super::history::user_terminated(history, CONTINUE_MESSAGE_ID, CONTINUE_INSTRUCTION)
};
let request = crate::model::ModelRequest {
prompt: prepared.prompt.clone(),
model: prepared.model.clone(),
@@ -296,7 +318,7 @@ impl RunEngine {
update_context_usage_anchor(
&mut context_usage_anchor,
cycle_usage,
request.history.len(),
anchored_messages,
);
accumulate_usage(&mut usage, cycle_usage);
}
@@ -306,7 +328,7 @@ impl RunEngine {
update_context_usage_anchor(
&mut context_usage_anchor,
cycle_usage,
request.history.len(),
anchored_messages,
);
accumulate_usage(&mut usage, cycle_usage);
}
@@ -353,7 +375,7 @@ impl RunEngine {
update_context_usage_anchor(
&mut context_usage_anchor,
cycle_usage,
request.history.len(),
anchored_messages,
);
accumulate_usage(&mut usage, cycle_usage);
}
@@ -361,6 +383,58 @@ impl RunEngine {
let _ = emit(client, RunEvent::CycleInterrupted).await;
return (RunOutcome::Cancelled, usage);
}
// The estimate that cleared the compaction check can
// still land over the real limit: it trails the
// provider's own count by whatever the request adds
// after the anchor was taken. The provider is the
// authority, so treat its refusal as the trigger the
// estimate missed. Without this a conversation that
// crosses the line is wedged: every retry rebuilds the
// same prompt and gets the same refusal.
if !overflow_compacted
&& prepared.action != RunAction::Compact
&& matches!(&cycle_failure.failure, RunFailure::Provider(message)
if super::compaction::is_context_overflow(message))
{
tracing::warn!(
provider_call_index,
checkpoint_id = checkpoint.0,
"provider rejected the prompt as over-limit; compacting and retrying"
);
overflow_compacted = true;
checkpoint = match super::messages::append_batches(
&self.store,
prepared,
client,
cancellation,
checkpoint,
std::mem::take(&mut pending_insertions),
)
.await
{
Ok((checkpoint, _)) => checkpoint,
Err(outcome) => return (outcome, usage),
};
let messages =
match self.store.load_checkpoint_messages(checkpoint).await {
Ok(messages) => messages,
Err(error) => return (RunOutcome::Failed(error.into()), usage),
};
match self
.auto_compact(prepared, checkpoint, &messages, client, cancellation)
.await
{
Ok((next_checkpoint, compaction_usage)) => {
checkpoint = next_checkpoint;
context_usage_anchor = None;
if let Some(compaction_usage) = compaction_usage {
accumulate_usage(&mut usage, compaction_usage);
}
continue 'model;
}
Err(outcome) => return (outcome, usage),
}
}
if !should_retry(&cycle_failure, retries) {
return (RunOutcome::Failed(cycle_failure.failure), usage);
}
@@ -459,7 +533,7 @@ impl RunEngine {
update_context_usage_anchor(
&mut context_usage_anchor,
cycle_usage,
request.history.len(),
anchored_messages,
);
accumulate_usage(&mut usage, cycle_usage);
}
@@ -721,6 +795,9 @@ impl RunEngine {
.await
.map_err(|error| RunOutcome::Failed(error.into()))?;
let history = crate::model::project_messages(&compactable)
.map(|history| {
super::compaction::compaction_history(history, prepared.model.context_window_tokens)
})
.map_err(|error| RunOutcome::Failed(error.into()))?;
let mut model = prepared.model.clone();
model.max_output_tokens = Some(super::compaction::OUTPUT_TOKENS);
+60
View File
@@ -0,0 +1,60 @@
//! Guarantees provider history ends with a user message before dispatch.
use crate::model::{ContentPart, ProjectedContent, ProjectedMessage, Role};
/// Appends a transient user message when the history ends with the assistant.
///
/// Providers read an assistant-terminated history as a prefill request, and
/// Anthropic refuses it outright: "This model does not support assistant
/// message prefill. The conversation must end with a user message." The
/// appended message is provider-visible only; it is never persisted, so the
/// committed checkpoint stays an exact prefix of the next turn.
pub(super) fn user_terminated(
mut history: Vec<ProjectedMessage>,
message_id: &str,
text: &str,
) -> Vec<ProjectedMessage> {
if history
.last()
.is_none_or(|message| message.role != Role::Assistant)
{
return history;
}
history.push(ProjectedMessage {
message_id: message_id.into(),
role: Role::User,
content: ProjectedContent::Parts(vec![ContentPart::Text { text: text.into() }]),
});
history
}
#[cfg(test)]
mod tests {
use super::*;
fn message(id: &str, role: Role) -> ProjectedMessage {
ProjectedMessage {
message_id: id.into(),
role,
content: ProjectedContent::Parts(vec![ContentPart::Text { text: id.into() }]),
}
}
#[test]
fn assistant_terminated_history_gains_a_user_tail() {
let history = vec![message("u1", Role::User), message("a1", Role::Assistant)];
let terminated = user_terminated(history.clone(), "tail", "continue");
assert_eq!(terminated[..2], history[..]);
assert_eq!(terminated.last().unwrap().role, Role::User);
assert_eq!(terminated.last().unwrap().message_id, "tail");
}
#[test]
fn user_and_tool_terminated_histories_are_untouched() {
let user = vec![message("a1", Role::Assistant), message("u2", Role::User)];
assert_eq!(user_terminated(user.clone(), "tail", "continue"), user);
let tool = vec![message("a1", Role::Assistant), message("t1", Role::Tool)];
assert_eq!(user_terminated(tool.clone(), "tail", "continue"), tool);
assert!(user_terminated(Vec::new(), "tail", "continue").is_empty());
}
}
+1
View File
@@ -5,6 +5,7 @@ mod compaction;
mod engine;
mod event;
mod handle;
mod history;
mod messages;
mod model_cycle;
mod model_retry;
+72 -1
View File
@@ -373,7 +373,12 @@ fn failure(
partial_reasoning: String,
usage: Option<Usage>,
) -> Box<ModelCycleFailure> {
let retryable = matches!(failure, RunFailure::Protocol(_) | RunFailure::Provider(_));
let retryable = match &failure {
RunFailure::Protocol(_) => true,
RunFailure::Provider(message) if is_rejected_request(message) => false,
RunFailure::Provider(_) => true,
RunFailure::Store(_) | RunFailure::Client(_) => false,
};
Box::new(ModelCycleFailure {
failure,
partial_text,
@@ -383,6 +388,21 @@ fn failure(
})
}
/// A rejected request (wrong key, unknown model, malformed or oversized body)
/// fails identically every time, so retrying it only delays the error the user
/// needs to see. Provider failures are formatted as `<label> <status>: <body>`,
/// so only the head before the body is inspected. 408 and 425 are timing
/// failures and stay retryable.
fn is_rejected_request(message: &str) -> bool {
let head = message.split_once(": ").map_or(message, |(head, _)| head);
head.split_whitespace().any(|token| {
token.len() == 3
&& token.starts_with('4')
&& token.bytes().all(|byte| byte.is_ascii_digit())
&& !matches!(token, "408" | "425" | "429")
})
}
fn terminal_failure(
failure: RunFailure,
partial_text: String,
@@ -480,4 +500,55 @@ mod tests {
assert_eq!(result.usage, Some(usage));
assert!(event_rx.try_recv().is_err(), "usage must be forwarded once");
}
#[test]
fn other_provider_errors_are_retryable() {
let result = failure(
RunFailure::Provider("Anthropic 502 Bad Gateway".into()),
String::new(),
String::new(),
None,
);
assert!(result.retryable);
let timeout = failure(
RunFailure::Provider("Anthropic 408 Request Timeout".into()),
String::new(),
String::new(),
None,
);
assert!(timeout.retryable);
}
#[test]
fn rejected_requests_are_not_retryable() {
// Verbatim from a wedged conversation: eight retries of the same
// over-limit prompt only delayed the error by forty seconds.
let too_long = failure(
RunFailure::Provider(
"Anthropic 400 Bad Request: {\"type\":\"error\",\"error\":{\"type\":\
\"invalid_request_error\",\"message\":\"prompt is too long: 1002148 \
tokens > 1000000 maximum\"}}"
.into(),
),
String::new(),
String::new(),
None,
);
assert!(!too_long.retryable);
let unauthorized = failure(
RunFailure::Provider("OpenAI Chat 401 Unauthorized: invalid api key".into()),
String::new(),
String::new(),
None,
);
assert!(!unauthorized.retryable);
// A status-looking number inside the body is not a status code.
let body_number = failure(
RunFailure::Provider("Anthropic 502 Bad Gateway: upstream returned 400".into()),
String::new(),
String::new(),
None,
);
assert!(body_number.retryable);
}
}
+191 -1
View File
@@ -174,7 +174,11 @@ async fn summarize_replaces_model_history_and_preserves_cursor_history() {
.prompt
.instructions
.contains("compacting conversation history"));
assert_eq!(requests[1].history.len(), 2);
assert_eq!(requests[1].history.len(), 3);
assert_eq!(
requests[1].history[2].message_id, "compaction:instruction",
"an assistant-terminated history gets the summarize instruction as its user tail"
);
assert_eq!(requests[2].history.len(), 2);
let ProjectedContent::Parts(summary_parts) = &requests[2].history[0].content else {
panic!("first post-compaction message must be the summary")
@@ -470,6 +474,192 @@ async fn irreducibly_oversized_current_input_fails_before_provider_dispatch() {
assert_eq!(output.summary_completed, 0);
}
fn windowed_model(model_id: &str, context_window_tokens: Option<u64>) -> ModelConfigInput {
ModelConfigInput {
sort_order: 0,
display_name: model_id.into(),
group_name: None,
model_type: ModelType::OpenAi,
base_url: "https://example.com/v1/chat/completions".into(),
use_full_url: true,
api_key: "test-key".into(),
tooltip_data: model_id.into(),
model_id: model_id.into(),
reasoning_effort: None,
openai_endpoint: OPENAI_CHAT_ENDPOINT.into(),
openai_extra_params_enabled: false,
openai_extra_params: serde_json::json!({}),
custom_headers_enabled: false,
custom_headers: serde_json::json!({}),
anthropic_extra_params_enabled: false,
anthropic_extra_params: serde_json::json!({}),
context_window_tokens,
max_completion_tokens: None,
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
}
}
#[tokio::test]
async fn provider_overflow_refusal_compacts_and_retries_once() {
// The estimate cleared the compaction check, but the provider counted
// more and refused. The refusal is the trigger the estimate missed.
let (_directory, store) = fixtures::temp_store().await;
let model = store
.create_model(&windowed_model("refusal-model", Some(1_000_000)))
.await
.unwrap();
let provider = fake_provider::FakeProvider::default();
provider.push(text_response("first answer", 4_000, 12));
provider.push_error(cursor_server::Error::Provider(
"Anthropic 400 Bad Request: {\"type\":\"error\",\"error\":{\"type\":\
\"invalid_request_error\",\"message\":\"prompt is too long: 1002148 tokens > \
1000000 maximum\"}}"
.into(),
));
provider.push(text_response("durable summary", 3_000, 20));
provider.push(text_response("answer after compaction", 500, 20));
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("prompt/cursor")
.as_path(),
)
.unwrap();
let registry = TransportRegistry::new(
store,
Arc::new(provider.clone()),
PromptCompiler::new(assets),
);
let first = run(
&registry,
"refusal-first",
user_request(
"refusal-conversation",
"refusal-user-1",
"start",
&model.model_hash,
None,
),
)
.await;
let second = run(
&registry,
"refusal-second",
user_request(
"refusal-conversation",
"refusal-user-2",
"continue",
&model.model_hash,
first.checkpoints.last().cloned(),
),
)
.await;
assert_eq!(second.summary_started, 1);
assert_eq!(second.summary_completed, 1);
assert_eq!(second.turn_ended, 1);
let requests = provider.requests();
assert_eq!(
requests.len(),
4,
"refused call, summary call, retried call"
);
assert!(requests[2].prompt.tools.is_empty());
assert_eq!(
requests[2].history.last().unwrap().role,
Role::User,
"the summarizer history must end with a user message"
);
assert!(!requests[3].prompt.tools.is_empty());
assert!(requests[3]
.history
.iter()
.any(|message| match &message.content {
ProjectedContent::Parts(parts) =>
matches!(parts.as_slice(), [ContentPart::Text { text }]
if text.contains("durable summary")),
_ => false,
}));
}
#[tokio::test]
async fn assistant_terminated_history_is_sent_with_a_user_tail() {
// Cursor can resume a conversation whose committed history already ends
// with the assistant. Anthropic refuses that as a prefill, so the run
// appends a provider-visible continuation without persisting it.
let (_directory, store) = fixtures::temp_store().await;
let model = store
.create_model(&windowed_model("tail-model", None))
.await
.unwrap();
let provider = fake_provider::FakeProvider::default();
provider.push(text_response("first answer", 400, 12));
provider.push(text_response("resumed answer", 450, 12));
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("prompt/cursor")
.as_path(),
)
.unwrap();
let registry = TransportRegistry::new(
store.clone(),
Arc::new(provider.clone()),
PromptCompiler::new(assets),
);
let first = run(
&registry,
"tail-first",
user_request(
"tail-conversation",
"tail-user-1",
"start",
&model.model_hash,
None,
),
)
.await;
let resumed = run(
&registry,
"tail-resume",
request(
"tail-conversation",
&model.model_hash,
first.checkpoints.last().cloned(),
pb::conversation_action::Action::ResumeAction(pb::ResumeAction::default()),
),
)
.await;
assert_eq!(resumed.turn_ended, 1);
let requests = provider.requests();
assert_eq!(requests.len(), 2);
let tail = requests[1].history.last().unwrap();
assert_eq!(tail.role, Role::User);
assert_eq!(tail.message_id, "runtime:continue");
assert_eq!(
requests[1].history[..requests[1].history.len() - 1]
.iter()
.map(|message| message.message_id.as_str())
.collect::<Vec<_>>()
.len(),
requests[0].history.len() + 1,
"committed history plus the first answer, then the transient tail"
);
let stored = store
.load_current_messages(&ConversationId::new("tail-conversation"))
.await
.unwrap();
assert!(
stored
.iter()
.all(|message| message.message_id != "runtime:continue"),
"the continuation tail is provider-visible only and never persisted"
);
}
#[derive(Default)]
struct Output {
checkpoints: Vec<pb::ConversationStateStructure>,